diff --git a/.env b/.env index 0df9cb42f4..4903a8b491 100644 --- a/.env +++ b/.env @@ -39,14 +39,23 @@ _APP_REDIS_HOST=redis _APP_REDIS_PORT=6379 _APP_REDIS_PASS= _APP_REDIS_USER= -COMPOSE_PROFILES=mongodb -_APP_DB_ADAPTER=mongodb -_APP_DB_HOST=mongodb -_APP_DB_PORT=27017 +COMPOSE_PROFILES=mariadb,mongodb,postgresql +_APP_DB_ADAPTER=mariadb +_APP_DB_HOST=mariadb +_APP_DB_PORT=3306 _APP_DB_SCHEMA=appwrite _APP_DB_USER=user _APP_DB_PASS=password _APP_DB_ROOT_PASS=rootsecretpassword +_APP_DB_ADAPTER_DOCUMENTSDB=mongodb +_APP_DB_HOST_DOCUMENTSDB=mongodb +_APP_DB_PORT_DOCUMENTSDB=27017 +_APP_DB_ADAPTER_VECTORSDB=postgresql +_APP_DB_HOST_VECTORSDB=postgresql +_APP_DB_PORT_VECTORSDB=5432 +_APP_EMBEDDING_MODELS=embeddinggemma +_APP_EMBEDDING_ENDPOINT='http://ollama:11434/api/embed' +_APP_EMBEDDING_TIMEOUT=30000 _APP_STORAGE_DEVICE=Local _APP_STORAGE_S3_ACCESS_KEY= _APP_STORAGE_S3_SECRET= diff --git a/AGENTS.md b/AGENTS.md index 993b0b5ad0..bb24d9f4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,36 @@ Examples: 'resourceType' => 'deployments' ``` +## Performance Patterns + +### Document Update Optimization + +When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`. + +**Correct Pattern:** +```php +// Good: Pass only changed attributes directly +$user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'name' => $name, + 'email' => $email, +])); +``` + +**Incorrect Pattern:** +```php +$user->setAttribute('name', $name); +$user->setAttribute('email', $email); + +// Bad: Passing full document is inefficient +$user = $dbForProject->updateDocument('users', $user->getId(), $user); +``` + +**Exceptions:** +- Migration files (need full document updates by design) +- Cases already using `array_merge()` with `getArrayCopy()` +- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document) +- Complex nested relationship logic where full document state is required + ## Security Considerations ### Critical Security Practices diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6837673d5..0ccc8e8372 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,7 @@ $ git push origin [name_of_your_new_branch] ## Setup From Source -To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and create run the docker-compose stack. +To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and run the docker-compose stack. > If you just want to install Appwrite for day-to-day use and not as a contributor, you can reference the [installation guide](https://github.com/appwrite/appwrite#installation), the [getting started guide](https://appwrite.io/docs/quick-starts), or the main [README](README.md) file. @@ -173,12 +173,12 @@ Learn more at our [Technology Stack](#technology-stack) section. - [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) - Appwrite console architecture ##### Container Namespace Conventions -To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on it's intended use. +To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on its intended use. `appwrite-worker-X` - Workers (`src/Appwrite/Platform/Workers/*`) `appwrite-task-X` - Tasks (`src/Appwrite/Platform/Tasks/*`) -Other containes should be named the same as their service, for example `redis` should just be called `redis`. +Other containers should be named the same as their service, for example `redis` should just be called `redis`. ##### Security @@ -189,7 +189,7 @@ Other containes should be named the same as their service, for example `redis` s ## Modules -As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesnt contain all product's features such as workers or tasks. While there might still be some occurances of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**. +As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesn't contain all product's features such as workers or tasks. While there might still be some occurrences of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**. ### HTTP Endpoints @@ -204,7 +204,7 @@ Tips and tricks: 1. If endpoint doesn't have resource, use service name as resource name too > Example: `Modules/Sites/Http/Sites/Get.php` -2. If there are multiple resources, use then all in folder structure +2. If there are multiple resources, use them all in folder structure > Example: `Modules/Sites/Http/Deployments/Builds/Create.php` 3. Action can only be `Get`, `Create`, `Update`, `Delete` or `XList` @@ -395,7 +395,7 @@ These are the current metrics we collect usage stats for: > Note: The curly brackets in the metric name represents a template and is replaced with a value when the metric is processed. -Metrics are collected within 3 scopes Daily, monthly, an infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect +Metrics are collected within 3 scopes Daily, monthly, and infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect statistics ,via API or via background worker. For both cases you will need to add a `const` variable in `app/init.php` under the usage metrics list using the naming convention `METRIC_` as shown below. ```php @@ -661,7 +661,7 @@ docker compose exec redis redis-cli FLUSHALL ## Using preview domains locally -Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud woekspaces such as Gitpod or GitHub Codespaces. +Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud workspaces such as Gitpod or GitHub Codespaces. To use preview domains on Cloud workspaces, you can visit hostname provided by them, and supply function's preview domain as URL parameter: diff --git a/Dockerfile b/Dockerfile index 161a425e80..266d4501d0 100755 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,7 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/sdks && \ chmod +x /usr/local/bin/specs && \ chmod +x /usr/local/bin/ssl && \ + chmod +x /usr/local/bin/time-travel && \ chmod +x /usr/local/bin/screenshot && \ chmod +x /usr/local/bin/test && \ chmod +x /usr/local/bin/upgrade && \ diff --git a/app/cli.php b/app/cli.php index 0f8426afd9..052643f004 100644 --- a/app/cli.php +++ b/app/cli.php @@ -318,6 +318,10 @@ $setResource('logError', function (Registry $register) { $setResource('executor', fn () => new Executor(), []); +$setResource('bus', function (Registry $register) use ($cli) { + return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name)); +}, ['register']); + $setResource('telemetry', fn () => new NoTelemetry(), []); $cli diff --git a/app/config/collections.php b/app/config/collections.php index a74e079dce..3af20ff2ac 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -4,6 +4,7 @@ $common = include __DIR__ . '/collections/common.php'; $projects = include __DIR__ . '/collections/projects.php'; $databases = include __DIR__ . '/collections/databases.php'; +$vectorsdb = include __DIR__ . '/collections/vectorsdb.php'; $platform = include __DIR__ . '/collections/platform.php'; $logs = include __DIR__ . '/collections/logs.php'; @@ -26,6 +27,7 @@ unset($common['files']); $collections = [ 'buckets' => $buckets, 'databases' => $databases, + 'vectorsdb' => $vectorsdb, 'projects' => array_merge_recursive($projects, $common), 'console' => array_merge_recursive($platform, $common), 'logs' => $logs, diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index ecd3db1733..e3d8d6baec 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -61,6 +61,15 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('database'), + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ] ], 'indexes' => [ [ @@ -777,6 +786,7 @@ return [ 'filters' => [], ], [ + // At the moment, always empty (no runtime supports it yet) 'array' => false, '$id' => ID::custom('startCommand'), 'type' => Database::VAR_STRING, @@ -787,17 +797,6 @@ return [ 'default' => null, 'filters' => [], ], - [ - 'array' => false, - '$id' => ID::custom('specification'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => false, - 'required' => false, - 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, - 'filters' => [], - ], [ 'array' => false, '$id' => ID::custom('buildSpecification'), @@ -1255,17 +1254,6 @@ return [ 'array' => false, 'filters' => [], ], - [ - 'array' => false, - '$id' => ID::custom('specification'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => false, - 'required' => false, - 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, - 'filters' => [], - ], [ 'array' => false, '$id' => ID::custom('buildSpecification'), @@ -2190,13 +2178,6 @@ return [ 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], - [ - '$id' => ID::custom('_key_function_internal_id'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceInternalId'], - 'lengths' => [], - 'orders' => [], - ], [ '$id' => ID::custom('_key_resourceType'), 'type' => Database::INDEX_KEY, diff --git a/app/config/collections/vectorsdb.php b/app/config/collections/vectorsdb.php new file mode 100644 index 0000000000..817863cffa --- /dev/null +++ b/app/config/collections/vectorsdb.php @@ -0,0 +1,165 @@ + [ + '$collection' => ID::custom('databases'), + '$id' => ID::custom('collections'), + 'name' => 'Collections', + 'attributes' => [ + [ + '$id' => ID::custom('databaseInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('databaseId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('dimension'), + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('enabled'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('documentSecurity'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('attributes'), + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['subQueryAttributes'], + ], + [ + '$id' => ID::custom('indexes'), + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['subQueryIndexes'], + ], + [ + '$id' => ID::custom('search'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + ], + 'defaultAttributes' => [ + [ + '$id' => ID::custom('embeddings'), + 'type' => Database::VAR_VECTOR, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('metadata'), + 'type' => Database::VAR_OBJECT, + 'default' => [], + 'required' => false, + 'size' => 0, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_fulltext_search'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_name'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + 'lengths' => [256], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_enabled'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['enabled'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_documentSecurity'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['documentSecurity'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + ], + 'defaultIndexes' => [ + // not creating default indexes on the embeddings as it depends on the type of query users using the most + [ + '$id' => ID::custom('_key_metadata'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['metadata'], + 'lengths' => [], + 'orders' => [], + ], + ] + ] +]; diff --git a/app/config/errors.php b/app/config/errors.php index bf0f4461f6..293c289f74 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -27,7 +27,7 @@ return [ Exception::GENERAL_RESOURCE_BLOCKED => [ 'name' => Exception::GENERAL_RESOURCE_BLOCKED, 'description' => 'Access to this resource is blocked.', - 'code' => 401, + 'code' => 403, ], Exception::GENERAL_UNKNOWN_ORIGIN => [ 'name' => Exception::GENERAL_UNKNOWN_ORIGIN, @@ -168,8 +168,8 @@ return [ ], Exception::USER_BLOCKED => [ 'name' => Exception::USER_BLOCKED, - 'description' => 'The current user has been blocked. You can unblock the user by making a request to the User API\'s "Update User Status" endpoint or in the Appwrite Console\'s Auth section.', - 'code' => 401, + 'description' => 'The current user has been blocked.', + 'code' => 403, ], Exception::USER_INVALID_TOKEN => [ 'name' => Exception::USER_INVALID_TOKEN, @@ -1201,6 +1201,11 @@ return [ 'description' => 'Migration is already in progress. You can check the status of the migration in your Appwrite Console\'s "Settings" > "Migrations".', 'code' => 409, ], + Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED => [ + 'name' => Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, + 'description' => 'The specified database type is not supported for CSV import or export operations.', + 'code' => 400, + ], /** Realtime */ Exception::REALTIME_MESSAGE_FORMAT_INVALID => [ diff --git a/app/config/sdks.php b/app/config/sdks.php index 77db161b5c..1a808aa10a 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -231,7 +231,7 @@ return [ 'url' => 'https://github.com/appwrite/sdk-for-cli', 'package' => 'https://www.npmjs.com/package/appwrite-cli', 'enabled' => true, - 'beta' => true, + 'beta' => false, 'dev' => false, 'hidden' => false, 'family' => APP_SDK_PLATFORM_CONSOLE, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0adf1b2d57..b58a9b4185 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -288,7 +288,10 @@ $createSession = function (string $userId, string $secret, Request $request, Res } try { - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'emailVerification' => $user->getAttribute('emailVerification'), + 'phoneVerification' => $user->getAttribute('phoneVerification'), + ])); } catch (\Throwable $th) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); } @@ -1032,7 +1035,11 @@ Http::post('/v1/account/sessions/email') ->setAttribute('password', $proofForPasswordUpdated->hash($password)) ->setAttribute('hash', $proofForPasswordUpdated->getHash()->getName()) ->setAttribute('hashOptions', $proofForPasswordUpdated->getHash()->getOptions()); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'password' => $user->getAttribute('password'), + 'hash' => $user->getAttribute('hash'), + 'hashOptions' => $user->getAttribute('hashOptions'), + ])); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -1822,7 +1829,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setAttribute('providerAccessToken', $accessToken) ->setAttribute('providerRefreshToken', $refreshToken) ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry)); - $dbForProject->updateDocument('identities', $identity->getId(), $identity); + $dbForProject->updateDocument('identities', $identity->getId(), new Document([ + 'providerAccessToken' => $identity->getAttribute('providerAccessToken'), + 'providerRefreshToken' => $identity->getAttribute('providerRefreshToken'), + 'providerAccessTokenExpiry' => $identity->getAttribute('providerAccessTokenExpiry'), + ])); } if (empty($user->getAttribute('email'))) { @@ -1960,7 +1971,10 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setAttribute('sessionId', $session->getId()) ->setAttribute('sessionInternalId', $session->getSequence()); - $dbForProject->updateDocument('targets', $target->getId(), $target); + $dbForProject->updateDocument('targets', $target->getId(), new Document([ + 'sessionId' => $target->getAttribute('sessionId'), + 'sessionInternalId' => $target->getAttribute('sessionInternalId'), + ])); } } @@ -3145,7 +3159,9 @@ Http::patch('/v1/account/name') $user->setAttribute('name', $name); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'name' => $user->getAttribute('name'), + ])); $queueForEvents->setParam('userId', $user->getId()); @@ -3798,13 +3814,15 @@ Http::put('/v1/account/recovery') $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); - $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile - ->setAttribute('password', $newPassword) - ->setAttribute('passwordHistory', $history) - ->setAttribute('passwordUpdate', DateTime::now()) - ->setAttribute('hash', $proofForPassword->getHash()->getName()) - ->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions()) - ->setAttribute('emailVerification', true)); + $profile = $dbForProject->updateDocument('users', $profile->getId(), new Document( + [ + 'password' => $newPassword, + 'passwordHistory' => $history, + 'passwordUpdate' => DateTime::now(), + 'hash' => $proofForPassword->getHash()->getName(), + 'hashOptions' => $proofForPassword->getHash()->getOptions(), + 'emailVerification' => true] + )); $user->setAttributes($profile->getArrayCopy()); @@ -4126,7 +4144,7 @@ Http::put('/v1/account/verifications/email') $authorization->addRole(Role::user($profile->getId())->toString()); - $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true)); + $profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['emailVerification' => true])); $user->setAttributes($profile->getArrayCopy()); @@ -4342,7 +4360,7 @@ Http::put('/v1/account/verifications/phone') $authorization->addRole(Role::user($profile->getId())->toString()); - $profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true)); + $profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['phoneVerification' => true])); $user->setAttributes($profile->getArrayCopy()); @@ -4500,7 +4518,11 @@ Http::put('/v1/account/targets/:targetId/push') $target->setAttribute('name', "{$device['deviceBrand']} {$device['deviceModel']}"); - $target = $dbForProject->updateDocument('targets', $target->getId(), $target); + $target = $dbForProject->updateDocument('targets', $target->getId(), new Document([ + 'identifier' => $target->getAttribute('identifier'), + 'expired' => $target->getAttribute('expired'), + 'name' => $target->getAttribute('name'), + ])); $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 3516af53d9..5e57eadc4e 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -46,6 +46,16 @@ use Utopia\Validator\WhiteList; include_once __DIR__ . '/../shared/api.php'; +function getDatabaseTransferResourceServices(string $databaseType) +{ + return match($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + }; +} + Http::post('/v1/migrations/appwrite') ->groups(['api', 'migrations']) ->desc('Create Appwrite migration') @@ -430,8 +440,16 @@ Http::post('/v1/migrations/csv/imports') throw new \Exception('Unable to copy file'); } + // getting databasetype + $resources = explode(':', $resourceId); + $databaseId = $resources[0]; + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } $fileSize = $deviceForMigrations->getFileSize($newPath); - $resources = Transfer::extractServices([Transfer::GROUP_DATABASES]); + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => $migrationId, @@ -560,13 +578,23 @@ Http::post('/v1/migrations/csv/exports') throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); } + // getting databasetype + $resources = explode(':', $resourceId); + $databaseId = $resources[0]; + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); + $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', 'stage' => 'init', 'source' => Appwrite::getName(), 'destination' => CSV::getName(), - 'resources' => Transfer::extractServices([Transfer::GROUP_DATABASES]), + 'resources' => $resources, 'resourceId' => $resourceId, 'resourceType' => Resource::TYPE_DATABASE, 'statusCounters' => '{}', @@ -769,26 +797,17 @@ Http::get('/v1/migrations/appwrite/report') ->param('projectID', '', new Text(512), "Source's Project ID") ->param('key', '', new Text(512), "Source's API Key") ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('user') - ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) { - - $appwrite = new Appwrite($projectID, $endpoint, $key); + ->inject('getDatabasesDB') + ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) { try { + $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB); $report = $appwrite->report($resources); } catch (\Throwable $e) { - switch ($e->getCode()) { - case 401: - throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage()); - case 429: - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?'); - case 500: - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); - } - - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); } $response @@ -827,21 +846,14 @@ Http::get('/v1/migrations/firebase/report') throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); } - $firebase = new Firebase($serviceAccount); - try { + $firebase = new Firebase($serviceAccount); $report = $firebase->report($resources); } catch (\Throwable $e) { - switch ($e->getCode()) { - case 401: - throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage()); - case 429: - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?'); - case 500: - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); - } - - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); } $response @@ -876,21 +888,14 @@ Http::get('/v1/migrations/supabase/report') ->inject('response') ->inject('dbForProject') ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) { - $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); - try { + $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); $report = $supabase->report($resources); } catch (\Throwable $e) { - switch ($e->getCode()) { - case 401: - throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage()); - case 429: - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?'); - case 500: - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); - } - - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); } $response @@ -925,21 +930,14 @@ Http::get('/v1/migrations/nhost/report') ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) ->inject('response') ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) { - $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); - try { + $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); $report = $nhost->report($resources); } catch (\Throwable $e) { - switch ($e->getCode()) { - case 401: - throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage()); - case 429: - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?'); - case 500: - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); - } - - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage()); + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); } $response diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index d24519e3fb..b79180c91c 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -62,16 +62,33 @@ Http::get('/v1/project/usage') METRIC_EXECUTIONS_MB_SECONDS, METRIC_BUILDS_MB_SECONDS, METRIC_DOCUMENTS, + METRIC_DOCUMENTS_DOCUMENTSDB, METRIC_DATABASES, + METRIC_DATABASES_DOCUMENTSDB, METRIC_USERS, METRIC_BUCKETS, METRIC_FILES_STORAGE, METRIC_DATABASES_STORAGE, + METRIC_DATABASES_STORAGE_DOCUMENTSDB, METRIC_DEPLOYMENTS_STORAGE, METRIC_BUILDS_STORAGE, METRIC_DATABASES_OPERATIONS_READS, + METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB, METRIC_DATABASES_OPERATIONS_WRITES, + METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB, METRIC_FILES_IMAGES_TRANSFORMED, + // VectorsDB totals + METRIC_DATABASES_VECTORSDB, + METRIC_COLLECTIONS_VECTORSDB, + METRIC_DOCUMENTS_VECTORSDB, + METRIC_DATABASES_STORAGE_VECTORSDB, + METRIC_DATABASES_OPERATIONS_READS_VECTORSDB, + METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB, + // Embeddings totals + METRIC_EMBEDDINGS_TEXT, + METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, + METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, + METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR ], 'period' => [ METRIC_NETWORK_REQUESTS, @@ -80,11 +97,26 @@ Http::get('/v1/project/usage') METRIC_USERS, METRIC_EXECUTIONS, METRIC_DATABASES_STORAGE, + METRIC_DATABASES_STORAGE_DOCUMENTSDB, METRIC_EXECUTIONS_MB_SECONDS, METRIC_BUILDS_MB_SECONDS, METRIC_DATABASES_OPERATIONS_READS, + METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB, METRIC_DATABASES_OPERATIONS_WRITES, + METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB, METRIC_FILES_IMAGES_TRANSFORMED, + // VectorsDB time series + METRIC_DATABASES_VECTORSDB, + METRIC_COLLECTIONS_VECTORSDB, + METRIC_DOCUMENTS_VECTORSDB, + METRIC_DATABASES_STORAGE_VECTORSDB, + METRIC_DATABASES_OPERATIONS_READS_VECTORSDB, + METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB, + // Embeddings time series + METRIC_EMBEDDINGS_TEXT, + METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, + METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, + METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR ] ]; @@ -357,8 +389,11 @@ Http::get('/v1/project/usage') 'buildsMbSecondsTotal' => $total[METRIC_BUILDS_MB_SECONDS], 'documentsTotal' => $total[METRIC_DOCUMENTS], 'rowsTotal' => $total[METRIC_DOCUMENTS], + 'documentsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_DOCUMENTSDB], 'databasesTotal' => $total[METRIC_DATABASES], + 'documentsdbTotal' => $total[METRIC_DATABASES_DOCUMENTSDB], 'databasesStorageTotal' => $total[METRIC_DATABASES_STORAGE], + 'documentsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_DOCUMENTSDB], 'usersTotal' => $total[METRIC_USERS], 'bucketsTotal' => $total[METRIC_BUCKETS], 'filesStorageTotal' => $total[METRIC_FILES_STORAGE], @@ -367,10 +402,27 @@ Http::get('/v1/project/usage') 'deploymentsStorageTotal' => $total[METRIC_DEPLOYMENTS_STORAGE], 'databasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS], 'databasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES], + 'documentsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB], + 'documentsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB], + 'vectorsdbDatabasesTotal' => $total[METRIC_DATABASES_VECTORSDB] ?? 0, + 'vectorsdbCollectionsTotal' => $total[METRIC_COLLECTIONS_VECTORSDB] ?? 0, + 'vectorsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_VECTORSDB] ?? 0, + 'vectorsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_VECTORSDB] ?? 0, + 'vectorsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? 0, + 'vectorsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? 0, 'executionsBreakdown' => $executionsBreakdown, 'bucketsBreakdown' => $bucketsBreakdown, 'databasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS], 'databasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES], + 'documentsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB], + 'documentsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB], + 'documentsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_DOCUMENTSDB], + 'vectorsdbDatabases' => $usage[METRIC_DATABASES_VECTORSDB] ?? [], + 'vectorsdbCollections' => $usage[METRIC_COLLECTIONS_VECTORSDB] ?? [], + 'vectorsdbDocuments' => $usage[METRIC_DOCUMENTS_VECTORSDB] ?? [], + 'vectorsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_VECTORSDB] ?? [], + 'vectorsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? [], + 'vectorsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? [], 'databasesStorageBreakdown' => $databasesStorageBreakdown, 'executionsMbSecondsBreakdown' => $executionsMbSecondsBreakdown, 'buildsMbSecondsBreakdown' => $buildsMbSecondsBreakdown, @@ -380,6 +432,14 @@ Http::get('/v1/project/usage') 'authPhoneCountryBreakdown' => $authPhoneCountryBreakdown, 'imageTransformations' => $usage[METRIC_FILES_IMAGES_TRANSFORMED], 'imageTransformationsTotal' => $total[METRIC_FILES_IMAGES_TRANSFORMED], + 'embeddingsText' => $usage[METRIC_EMBEDDINGS_TEXT] ?? [], + 'embeddingsTextTokens' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? [], + 'embeddingsTextDuration' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? [], + 'embeddingsTextErrors' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? [], + 'embeddingsTextTotal' => $total[METRIC_EMBEDDINGS_TEXT] ?? 0, + 'embeddingsTextTokensTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? 0, + 'embeddingsTextDurationTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? 0, + 'embeddingsTextErrorsTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? 0, ]), Response::MODEL_USAGE_PROJECT); }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 38c0e87b44..9d04018b10 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1161,7 +1161,7 @@ Http::patch('/v1/users/:userId/status') throw new Exception(Exception::USER_NOT_FOUND); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', (bool) $status)); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['status' => (bool) $status])); $queueForEvents ->setParam('userId', $user->getId()); @@ -1204,7 +1204,7 @@ Http::put('/v1/users/:userId/labels') $user->setAttribute('labels', (array) \array_values(\array_unique($labels))); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['labels' => $user->getAttribute('labels')])); $queueForEvents ->setParam('userId', $user->getId()); @@ -1245,7 +1245,7 @@ Http::patch('/v1/users/:userId/verification/phone') throw new Exception(Exception::USER_NOT_FOUND); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('phoneVerification', $phoneVerification)); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['phoneVerification' => $phoneVerification])); $queueForEvents ->setParam('userId', $user->getId()); @@ -1289,7 +1289,7 @@ Http::patch('/v1/users/:userId/name') $user->setAttribute('name', $name); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['name' => $user->getAttribute('name')])); $queueForEvents->setParam('userId', $user->getId()); @@ -1344,7 +1344,10 @@ Http::patch('/v1/users/:userId/password') ->setAttribute('password', '') ->setAttribute('passwordUpdate', DateTime::now()); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'password' => $user->getAttribute('password'), + 'passwordUpdate' => $user->getAttribute('passwordUpdate'), + ])); $queueForEvents->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); } @@ -1377,7 +1380,13 @@ Http::patch('/v1/users/:userId/password') ->setAttribute('hash', $hasher->getName()) ->setAttribute('hashOptions', $hasher->getOptions()); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'password' => $user->getAttribute('password'), + 'passwordHistory' => $user->getAttribute('passwordHistory'), + 'passwordUpdate' => $user->getAttribute('passwordUpdate'), + 'hash' => $user->getAttribute('hash'), + 'hashOptions' => $user->getAttribute('hashOptions'), + ])); $sessions = $user->getAttribute('sessions', []); $invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false; @@ -1469,7 +1478,15 @@ Http::patch('/v1/users/:userId/email') ; try { - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'email' => $user->getAttribute('email'), + 'emailVerification' => $user->getAttribute('emailVerification'), + 'emailCanonical' => $user->getAttribute('emailCanonical'), + 'emailIsCanonical' => $user->getAttribute('emailIsCanonical'), + 'emailIsCorporate' => $user->getAttribute('emailIsCorporate'), + 'emailIsDisposable' => $user->getAttribute('emailIsDisposable'), + 'emailIsFree' => $user->getAttribute('emailIsFree'), + ])); /** * @var Document $oldTarget */ @@ -1477,7 +1494,8 @@ Http::patch('/v1/users/:userId/email') if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { if (\strlen($email) !== 0) { - $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email)); + $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email])); + $oldTarget->setAttribute('identifier', $email); } else { $dbForProject->deleteDocument('targets', $oldTarget->getId()); } @@ -1542,12 +1560,15 @@ Http::patch('/v1/users/:userId/phone') $oldPhone = $user->getAttribute('phone'); + // Store null instead of empty string so unique constraint allows multiple users without phone + $phoneValue = $number !== '' ? $number : null; + $user - ->setAttribute('phone', $number) + ->setAttribute('phone', $phoneValue) ->setAttribute('phoneVerification', false) ; - if (\strlen($number) !== 0) { + if ($number !== '') { $target = $dbForProject->findOne('targets', [ Query::equal('identifier', [$number]), ]); @@ -1558,20 +1579,24 @@ Http::patch('/v1/users/:userId/phone') } try { - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'phone' => $phoneValue, + 'phoneVerification' => $user->getAttribute('phoneVerification'), + ])); /** * @var Document $oldTarget */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { - if (\strlen($number) !== 0) { - $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $number)); + if ($number !== '') { + $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); + $oldTarget->setAttribute('identifier', $number); } else { $dbForProject->deleteDocument('targets', $oldTarget->getId()); } } else { - if (\strlen($number) !== 0) { + if ($number !== '') { $target = $dbForProject->createDocument('targets', new Document([ '$permissions' => [ Permission::read(Role::user($user->getId())), @@ -1630,7 +1655,7 @@ Http::patch('/v1/users/:userId/verification') throw new Exception(Exception::USER_NOT_FOUND); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification)); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['emailVerification' => $emailVerification])); $queueForEvents->setParam('userId', $user->getId()); @@ -1668,7 +1693,7 @@ Http::patch('/v1/users/:userId/prefs') throw new Exception(Exception::USER_NOT_FOUND); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['prefs' => $prefs])); $queueForEvents ->setParam('userId', $user->getId()); @@ -1768,7 +1793,13 @@ Http::patch('/v1/users/:userId/targets/:targetId') $target->setAttribute('name', $name); } - $target = $dbForProject->updateDocument('targets', $target->getId(), $target); + $target = $dbForProject->updateDocument('targets', $target->getId(), new Document([ + 'identifier' => $target->getAttribute('identifier'), + 'expired' => $target->getAttribute('expired'), + 'providerId' => $target->getAttribute('providerId'), + 'providerInternalId' => $target->getAttribute('providerInternalId'), + 'name' => $target->getAttribute('name'), + ])); $dbForProject->purgeCachedDocument('users', $user->getId()); $queueForEvents @@ -1836,7 +1867,7 @@ Http::patch('/v1/users/:userId/mfa') $user->setAttribute('mfa', $mfa); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['mfa' => $user->getAttribute('mfa')])); $queueForEvents->setParam('userId', $user->getId()); @@ -2024,7 +2055,7 @@ Http::patch('/v1/users/:userId/mfa/recovery-codes') $mfaRecoveryCodes = Type::generateBackupCodes(); $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes])); $queueForEvents->setParam('userId', $user->getId()); @@ -2096,7 +2127,7 @@ Http::put('/v1/users/:userId/mfa/recovery-codes') $mfaRecoveryCodes = Type::generateBackupCodes(); $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes])); $queueForEvents->setParam('userId', $user->getId()); diff --git a/app/controllers/general.php b/app/controllers/general.php index 2ac03368df..f77aa3ec52 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -5,11 +5,11 @@ require_once __DIR__ . '/../init.php'; use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; +use Appwrite\Bus\Events\ExecutionCompleted; +use Appwrite\Bus\Events\RequestCompleted; use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; -use Appwrite\Event\Execution; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; use Appwrite\Platform\Appwrite; @@ -30,11 +30,14 @@ use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; use Appwrite\Utopia\Response\Filters\V18 as ResponseV18; use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; +use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; +use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; use Swoole\Http\Request as SwooleRequest; use Swoole\Table; +use Utopia\Bus\Bus; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; @@ -62,7 +65,7 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) +function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { @@ -131,8 +134,9 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { - $project->setAttribute('accessedAt', DateTime::now()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'accessedAt' => DateTime::now() + ]))); } /** @@ -320,7 +324,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S }; $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); - $spec = Config::getParam('specifications')[$resource->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + $spec = Config::getParam('specifications')[$resource->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, @@ -550,6 +554,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } + if (!empty($deployment->getAttribute('startCommand', ''))) { + $startCommand = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + } + $runtimeEntrypoint = match ($version) { 'v2' => '', default => "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$startCommand\"", @@ -706,10 +714,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } finally { if ($type === 'function' || $type === 'site') { - $queueForExecutions - ->setExecution($execution) - ->setProject($project) - ->trigger(); + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + resource: $resource->getArrayCopy(), + )); } } @@ -754,70 +764,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S ->setStatusCode($execution['responseStatusCode'] ?? 200) ->send($body); - $fileSize = 0; - $file = $request->getFiles('file'); - if (!empty($file)) { - $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; - } - - if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) { - foreach ($apiKey->getDisabledMetrics() as $key) { - $queueForStatsUsage->disableMetric($key); - } - } - - $metricTypeExecutions = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS); - $metricTypeIdExecutions = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS); - $metricTypeExecutionsCompute = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE); - $metricTypeIdExecutionsCompute = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE); - $metricTypeExecutionsMbSeconds = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS); - $metricTypeIdExecutionsMBSeconds = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS); - if ($deployment->getAttribute('resourceType') === 'sites') { - $queueForStatsUsage - ->disableMetric(METRIC_NETWORK_REQUESTS) - ->disableMetric(METRIC_NETWORK_INBOUND) - ->disableMetric(METRIC_NETWORK_OUTBOUND); - if ($resource->getAttribute('adapter') !== 'ssr') { - $queueForStatsUsage - ->disableMetric(METRIC_EXECUTIONS) - ->disableMetric(METRIC_EXECUTIONS_COMPUTE) - ->disableMetric(METRIC_EXECUTIONS_MB_SECONDS) - ->disableMetric($metricTypeExecutions) - ->disableMetric($metricTypeIdExecutions) - ->disableMetric($metricTypeExecutionsCompute) - ->disableMetric($metricTypeIdExecutionsCompute) - ->disableMetric($metricTypeExecutionsMbSeconds) - ->disableMetric($metricTypeIdExecutionsMBSeconds); - } - - $queueForStatsUsage - ->addMetric(METRIC_SITES_REQUESTS, 1) - ->addMetric(METRIC_SITES_INBOUND, $request->getSize() + $fileSize) - ->addMetric(METRIC_SITES_OUTBOUND, $response->getSize()) - ->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_REQUESTS), 1) - ->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_INBOUND), $request->getSize() + $fileSize) - ->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_OUTBOUND), $response->getSize()) - ; - } - - - $compute = (int)($execution->getAttribute('duration') * 1000); - $mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT)); - $queueForStatsUsage - ->addMetric(METRIC_NETWORK_REQUESTS, 1) - ->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize) - ->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize()) - ->addMetric(METRIC_EXECUTIONS, 1) - ->addMetric($metricTypeExecutions, 1) - ->addMetric($metricTypeIdExecutions, 1) - ->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute) // per project - ->addMetric($metricTypeExecutionsCompute, $compute) // per function - ->addMetric($metricTypeIdExecutionsCompute, $compute) // per function - ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds) - ->addMetric($metricTypeExecutionsMbSeconds, $mbSeconds) - ->addMetric($metricTypeIdExecutionsMBSeconds, $mbSeconds) - ->setProject($project) - ->trigger(); + $bus->dispatch(new RequestCompleted( + project: $project->getArrayCopy(), + request: $request, + response: $response, + deployment: $deployment->getArrayCopy(), + )); /* cleanup */ if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { @@ -881,9 +833,8 @@ Http::init() ->inject('locale') ->inject('localeCodes') ->inject('geodb') - ->inject('queueForStatsUsage') ->inject('queueForEvents') - ->inject('queueForExecutions') + ->inject('bus') ->inject('executor') ->inject('platform') ->inject('isResourceBlocked') @@ -894,7 +845,7 @@ Http::init() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ @@ -902,7 +853,7 @@ Http::init() $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -988,17 +939,23 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { - if (version_compare($responseFormat, '1.4.0', '<')) { - $response->addFilter(new ResponseV16()); + if (version_compare($responseFormat, '1.9.0', '<')) { + $response->addFilter(new ResponseV21()); } - if (version_compare($responseFormat, '1.5.0', '<')) { - $response->addFilter(new ResponseV17()); + if (version_compare($responseFormat, '1.8.0', '<')) { + $response->addFilter(new ResponseV20()); + } + if (version_compare($responseFormat, '1.7.0', '<')) { + $response->addFilter(new ResponseV19()); } if (version_compare($responseFormat, '1.6.0', '<')) { $response->addFilter(new ResponseV18()); } - if (version_compare($responseFormat, '1.7.0', '<')) { - $response->addFilter(new ResponseV19()); + if (version_compare($responseFormat, '1.5.0', '<')) { + $response->addFilter(new ResponseV17()); + } + if (version_compare($responseFormat, '1.4.0', '<')) { + $response->addFilter(new ResponseV16()); } if (version_compare($responseFormat, APP_VERSION_STABLE, '>')) { $warnings[] = "The current SDK is built for Appwrite " . $responseFormat . ". However, the current Appwrite server version is " . APP_VERSION_STABLE . ". Please downgrade your SDK to match the Appwrite version: https://appwrite.io/docs/sdks"; @@ -1178,8 +1135,7 @@ Http::options() ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForExecutions') + ->inject('bus') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1192,14 +1148,14 @@ Http::options() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1215,12 +1171,11 @@ Http::options() /** OPTIONS requests in utopia do not execute shutdown handlers, as a result we need to track the OPTIONS requests explicitly * @see https://github.com/utopia-php/http/blob/0.33.16/src/App.php#L825-L855 */ - $queueForStatsUsage - ->addMetric(METRIC_NETWORK_REQUESTS, 1) - ->addMetric(METRIC_NETWORK_INBOUND, $request->getSize()) - ->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize()) - ->setProject($project) - ->trigger(); + $bus->dispatch(new RequestCompleted( + project: $project->getArrayCopy(), + request: $request, + response: $response, + )); }); Http::error() @@ -1231,10 +1186,10 @@ Http::error() ->inject('project') ->inject('logger') ->inject('log') - ->inject('queueForStatsUsage') + ->inject('bus') ->inject('devKey') ->inject('authorization') - ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage, Document $devKey, Authorization $authorization) { + ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); $route = $utopia->getRoute(); $class = \get_class($error); @@ -1307,21 +1262,12 @@ Http::error() */ if (!$publish && $project->getId() !== 'console') { if (!DBUser::isPrivileged($authorization->getRoles())) { - $fileSize = 0; - $file = $request->getFiles('file'); - if (!empty($file)) { - $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; - } - - $queueForStatsUsage - ->addMetric(METRIC_NETWORK_REQUESTS, 1) - ->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize) - ->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize()); + $bus->dispatch(new RequestCompleted( + project: $project->getArrayCopy(), + request: $request, + response: $response, + )); } - - $queueForStatsUsage - ->setProject($project) - ->trigger(); } if ($logger && $publish) { @@ -1453,6 +1399,9 @@ Http::error() $sdk = $route?->getLabel("sdk", false); $action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD'; if (!empty($sdk)) { + if (\is_array($sdk)) { + $sdk = $sdk[0]; + } /** @var \Appwrite\SDK\Method $sdk */ $action = $sdk->getNamespace() . '.' . $sdk->getMethodName(); } elseif ($route === null) { @@ -1568,8 +1517,7 @@ Http::get('/robots.txt') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForExecutions') + ->inject('bus') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1579,13 +1527,13 @@ Http::get('/robots.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/robots.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1603,8 +1551,7 @@ Http::get('/humans.txt') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForExecutions') + ->inject('bus') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1614,13 +1561,13 @@ Http::get('/humans.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/humans.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1718,7 +1665,10 @@ Http::get('/v1/ping') ->setAttribute('pingedAt', $pingedAt); $authorization->skip(function () use ($dbForPlatform, $project) { - $dbForPlatform->updateDocument('projects', $project->getId(), $project); + $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'pingCount' => $project->getAttribute('pingCount'), + 'pingedAt' => $project->getAttribute('pingedAt') + ])); }); $queueForEvents diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 56090f100a..87cb30cb0f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -2,6 +2,7 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; +use Appwrite\Bus\Events\RequestCompleted; use Appwrite\Event\Audit; use Appwrite\Event\Build; use Appwrite\Event\Database as EventDatabase; @@ -21,6 +22,7 @@ use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Utopia\Abuse\Abuse; +use Utopia\Bus\Bus; use Utopia\Cache\Adapter\Filesystem; use Utopia\Cache\Cache; use Utopia\Config\Config; @@ -212,6 +214,8 @@ Http::init() } if (!$dbKey) { + \var_dump($apiKey); + \var_dump($request->getHeader('x-appwrite-key', '')); throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -375,8 +379,9 @@ Http::init() if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { - $project->setAttribute('accessedAt', DateTime::now()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'accessedAt' => DateTime::now() + ]))); } } @@ -386,9 +391,13 @@ Http::init() $user->setAttribute('accessedAt', DateTime::now()); if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) { - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'accessedAt' => $user->getAttribute('accessedAt') + ])); } else { - $authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), $user)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), new Document([ + 'accessedAt' => $user->getAttribute('accessedAt') + ]))); } } } @@ -478,6 +487,12 @@ Http::init() ->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $route = $utopia->getRoute(); + $path = $route->getMatchedPath(); + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; if ( array_key_exists('rest', $project->getAttribute('apis', [])) @@ -664,7 +679,9 @@ Http::init() $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); - $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file)); + $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), new Document([ + 'transformedAt' => $file->getAttribute('transformedAt') + ]))); } } } @@ -763,7 +780,8 @@ Http::shutdown() ->inject('authorization') ->inject('timelimit') ->inject('eventProcessor') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor) use ($parseLabel) { + ->inject('bus') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -962,7 +980,9 @@ Http::shutdown() } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { $cacheLog->setAttribute('accessedAt', $now); - $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog)); + $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ + 'accessedAt' => $cacheLog->getAttribute('accessedAt') + ]))); // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -975,16 +995,11 @@ Http::shutdown() if ($project->getId() !== 'console') { if (!User::isPrivileged($authorization->getRoles())) { - $fileSize = 0; - $file = $request->getFiles('file'); - if (!empty($file)) { - $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; - } - - $queueForStatsUsage - ->addMetric(METRIC_NETWORK_REQUESTS, 1) - ->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize) - ->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize()); + $bus->dispatch(new RequestCompleted( + project: $project->getArrayCopy(), + request: $request, + response: $response, + )); } $queueForStatsUsage diff --git a/app/http.php b/app/http.php index 30c4a8317b..517a1aa544 100644 --- a/app/http.php +++ b/app/http.php @@ -188,10 +188,16 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) { Console::success('Reload completed...'); }); +Http::setResource('bus', function ($register, $utopia) { + return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name)); +}, ['register', 'utopia']); + include __DIR__ . '/controllers/general.php'; function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void { + $max = 15; + $sleep = 2; $max = 15; $sleep = 2; $attempts = 0; @@ -405,13 +411,29 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot }); $projectCollections = $collections['projects']; + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1); + $documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); + $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1); + + $vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); + $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1); + $cache = $app->getResource('cache'); - foreach ($sharedTablesV2 as $hostname) { + // All shared tables V2 pools that need project metadata collections + $sharedTablesV2All = \array_values(\array_unique(\array_filter([ + ...$sharedTablesV2, + ...$documentsSharedTablesV2, + ...$vectorSharedTablesV2, + ]))); + + foreach ($sharedTablesV2All as $hostname) { Span::init('database.setup'); Span::add('database.hostname', $hostname); @@ -577,6 +599,9 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool $action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD'; if (!empty($sdk)) { + if (\is_array($sdk)) { + $sdk = $sdk[0]; + } /** @var Appwrite\SDK\Method $sdk */ $action = $sdk->getNamespace() . '.' . $sdk->getMethodName(); } elseif ($route === null) { diff --git a/app/init/constants.php b/app/init/constants.php index 73835e5525..23f3959f5d 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -94,6 +94,7 @@ const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite?sub_confirmation= const APP_COMPUTE_CPUS_DEFAULT = 0.5; const APP_COMPUTE_MEMORY_DEFAULT = 512; const APP_COMPUTE_SPECIFICATION_DEFAULT = Specification::S_1VCPU_512MB; +const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years const APP_SDK_PLATFORM_SERVER = 'server'; const APP_SDK_PLATFORM_CLIENT = 'client'; const APP_SDK_PLATFORM_CONSOLE = 'console'; @@ -290,6 +291,45 @@ const METRIC_DATABASES_OPERATIONS_READS = 'databases.operations.reads'; const METRIC_DATABASE_ID_OPERATIONS_READS = '{databaseInternalId}.databases.operations.reads'; const METRIC_DATABASES_OPERATIONS_WRITES = 'databases.operations.writes'; const METRIC_DATABASE_ID_OPERATIONS_WRITES = '{databaseInternalId}.databases.operations.writes'; + +// documentsdb +const METRIC_DATABASES_DOCUMENTSDB = 'documentsdb.databases'; +const METRIC_COLLECTIONS_DOCUMENTSDB = 'documentsdb.collections'; +const METRIC_DATABASES_STORAGE_DOCUMENTSDB = 'documentsdb.databases.storage'; +const METRIC_DATABASE_ID_COLLECTIONS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.collections'; +const METRIC_DATABASE_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.storage'; +const METRIC_DOCUMENTS_DOCUMENTSDB = 'documentsdb.documents'; +const METRIC_DATABASE_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.databases.storage'; +const METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.databases.operations.reads'; +const METRIC_DATABASE_ID_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.reads'; +const METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.databases.operations.writes'; +const METRIC_DATABASE_ID_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.writes'; + +// vectorsdb +const METRIC_DATABASES_VECTORSDB = 'vectorsdb.databases'; +const METRIC_COLLECTIONS_VECTORSDB = 'vectorsdb.collections'; +const METRIC_DATABASES_STORAGE_VECTORSDB = 'vectorsdb.databases.storage'; +const METRIC_DATABASE_ID_COLLECTIONS_VECTORSDB = 'vectorsdb.{databaseInternalId}.collections'; +const METRIC_DATABASE_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.storage'; +const METRIC_DOCUMENTS_VECTORSDB = 'vectorsdb.documents'; +const METRIC_DATABASE_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.databases.storage'; +const METRIC_DATABASES_OPERATIONS_READS_VECTORSDB = 'vectorsdb.databases.operations.reads'; +const METRIC_DATABASE_ID_OPERATIONS_READS_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.reads'; +const METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.databases.operations.writes'; +const METRIC_DATABASE_ID_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.writes'; +const METRIC_EMBEDDINGS_TEXT = 'embeddings.text'; +const METRIC_EMBEDDINGS_MODEL_TEXT = 'embeddings.text.{embeddingModel}'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR = 'embeddings.text.totalErrors'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR = 'embeddings.text.{embeddingModel}.totalErrors'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION = 'embeddings.text.totalDuration'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION = 'embeddings.text.{embeddingModel}.totalDuration'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS = 'embeddings.text.totalTokens'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS = 'embeddings.text.{embeddingModel}.totalTokens'; + const METRIC_BUCKETS = 'buckets'; const METRIC_FILES = 'files'; const METRIC_FILES_STORAGE = 'files.storage'; @@ -364,6 +404,12 @@ const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated'; const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}'; const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}'; +// Realtime metrics +const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; +const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; +const METRIC_REALTIME_INBOUND = 'realtime.inbound'; +const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; + // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; const RESOURCE_TYPE_FUNCTIONS = 'functions'; @@ -376,6 +422,7 @@ const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; const RESOURCE_TYPE_EXECUTIONS = 'executions'; const RESOURCE_TYPE_VCS = 'vcs'; +const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; @@ -397,3 +444,16 @@ const CACHE_RECONNECT_RETRY_DELAY = 1000; // Project status const PROJECT_STATUS_ACTIVE = 'active'; + +// Database types +const DATABASE_TYPE_LEGACY = 'legacy'; +const DATABASE_TYPE_TABLESDB = 'tablesdb'; +const DATABASE_TYPE_DOCUMENTSDB = 'documentsdb'; +const DATABASE_TYPE_VECTORSDB = 'vectorsdb'; + +// CSV import/export allowed database types +const CSV_ALLOWED_DATABASE_TYPES = [ + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB, + DATABASE_TYPE_VECTORSDB +]; diff --git a/app/init/models.php b/app/init/models.php index 3016d6f8cd..5fdfd804e5 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -22,6 +22,7 @@ use Appwrite\Utopia\Response\Model\AttributeLine; use Appwrite\Utopia\Response\Model\AttributeList; use Appwrite\Utopia\Response\Model\AttributeLongtext; use Appwrite\Utopia\Response\Model\AttributeMediumtext; +use Appwrite\Utopia\Response\Model\AttributeObject; use Appwrite\Utopia\Response\Model\AttributePoint; use Appwrite\Utopia\Response\Model\AttributePolygon; use Appwrite\Utopia\Response\Model\AttributeRelationship; @@ -29,6 +30,7 @@ use Appwrite\Utopia\Response\Model\AttributeString; use Appwrite\Utopia\Response\Model\AttributeText; use Appwrite\Utopia\Response\Model\AttributeURL; use Appwrite\Utopia\Response\Model\AttributeVarchar; +use Appwrite\Utopia\Response\Model\AttributeVector; use Appwrite\Utopia\Response\Model\AuthProvider; use Appwrite\Utopia\Response\Model\BaseList; use Appwrite\Utopia\Response\Model\Branch; @@ -65,6 +67,7 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; +use Appwrite\Utopia\Response\Model\Embedding; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; use Appwrite\Utopia\Response\Model\Execution; @@ -137,6 +140,8 @@ use Appwrite\Utopia\Response\Model\UsageBuckets; use Appwrite\Utopia\Response\Model\UsageCollection; use Appwrite\Utopia\Response\Model\UsageDatabase; use Appwrite\Utopia\Response\Model\UsageDatabases; +use Appwrite\Utopia\Response\Model\UsageDocumentsDB; +use Appwrite\Utopia\Response\Model\UsageDocumentsDBs; use Appwrite\Utopia\Response\Model\UsageFunction; use Appwrite\Utopia\Response\Model\UsageFunctions; use Appwrite\Utopia\Response\Model\UsageProject; @@ -145,9 +150,12 @@ use Appwrite\Utopia\Response\Model\UsageSites; use Appwrite\Utopia\Response\Model\UsageStorage; use Appwrite\Utopia\Response\Model\UsageTable; use Appwrite\Utopia\Response\Model\UsageUsers; +use Appwrite\Utopia\Response\Model\UsageVectorsDB; +use Appwrite\Utopia\Response\Model\UsageVectorsDBs; use Appwrite\Utopia\Response\Model\User; use Appwrite\Utopia\Response\Model\Variable; use Appwrite\Utopia\Response\Model\VcsContent; +use Appwrite\Utopia\Response\Model\VectorsDBCollection; use Appwrite\Utopia\Response\Model\Webhook; // General @@ -212,9 +220,12 @@ Response::setModel(new BaseList('Migrations List', Response::MODEL_MIGRATION_LIS Response::setModel(new BaseList('Migrations Firebase Projects List', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', Response::MODEL_MIGRATION_FIREBASE_PROJECT)); Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICATION_LIST, 'specifications', Response::MODEL_SPECIFICATION)); Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT)); +Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION)); +Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING)); // Entities Response::setModel(new Database()); +Response::setModel(new Embedding()); // Collection API Models Response::setModel(new Collection()); @@ -238,6 +249,17 @@ Response::setModel(new AttributeText()); Response::setModel(new AttributeMediumtext()); Response::setModel(new AttributeLongtext()); +// DocumentsDB API Models +Response::setModel(new UsageDocumentsDBs()); +Response::setModel(new UsageDocumentsDB()); + +// VectorsDB API Models +Response::setModel(new VectorsDBCollection()); +Response::setModel(new AttributeObject()); +Response::setModel(new AttributeVector()); +Response::setModel(new UsageVectorsDBs()); +Response::setModel(new UsageVectorsDB()); + // Table API Models Response::setModel(new Table()); Response::setModel(new Column()); diff --git a/app/init/registers.php b/app/init/registers.php index 411fd4c69d..7c2f822fdd 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -160,7 +160,6 @@ $register->set('pools', function () { 'pass' => System::getEnv('_APP_DB_PASS', ''), 'path' => System::getEnv('_APP_DB_SCHEMA', ''), ]); - $fallbackForRedis = 'redis_main=' . AppwriteURL::unparse([ 'scheme' => 'redis', 'host' => System::getEnv('_APP_REDIS_HOST', 'redis'), @@ -169,6 +168,23 @@ $register->set('pools', function () { 'pass' => System::getEnv('_APP_REDIS_PASS', ''), ]); + $fallbackForDocumentsDB = 'db_main=' . AppwriteURL::unparse([ + 'scheme' => System::getEnv('_APP_DB_ADAPTER_DOCUMENTSDB', 'mongodb'), + 'host' => System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'), + 'port' => System::getEnv('_APP_DB_PORT_DOCUMENTSDB', '27017'), + 'user' => System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + ]); + $fallbackForVectorsDB = 'db_main=' . AppwriteURL::unparse([ + 'scheme' => System::getEnv('_APP_DB_ADAPTER_VECTORSDB', 'postgresql'), + 'host' => System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'), + 'port' => System::getEnv('_APP_DB_PORT_VECTORSDB', '5432'), + 'user' => System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + ]); + $connections = [ 'console' => [ 'type' => 'database', @@ -180,13 +196,25 @@ $register->set('pools', function () { 'type' => 'database', 'dsns' => $fallbackForDB, 'multiple' => true, - 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'], + 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'], + ], + 'documentsdb' => [ + 'type' => 'database', + 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_DOCUMENTSDB', $fallbackForDocumentsDB), + 'multiple' => true, + 'schemes' => ['mongodb'], + ], + 'vectorsdb' => [ + 'type' => 'database', + 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_VECTORSDB', $fallbackForVectorsDB), + 'multiple' => true, + 'schemes' => ['postgresql'], ], 'logs' => [ 'type' => 'database', 'dsns' => System::getEnv('_APP_CONNECTIONS_DB_LOGS', $fallbackForDB), 'multiple' => false, - 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'], + 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'], ], 'publisher' => [ 'type' => 'publisher', @@ -420,6 +448,7 @@ $register->set('smtp', function () { $mail->Password = $password; $mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', ''); $mail->SMTPAutoTLS = false; + $mail->SMTPKeepAlive = true; $mail->CharSet = 'UTF-8'; $mail->Timeout = 10; /* Connection timeout */ $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ @@ -449,3 +478,11 @@ $register->set('promiseAdapter', function () { $register->set('hooks', function () { return new Hooks(); }); +$listeners = require __DIR__ . '/../listeners.php'; +$register->set('bus', function () use ($listeners) { + $bus = new \Utopia\Bus\Bus(); + foreach ($listeners as $listener) { + $bus->subscribe($listener); + } + return $bus; +}); diff --git a/app/init/resources.php b/app/init/resources.php index f3e66c388a..5b989f781b 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -10,7 +10,6 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; -use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -32,6 +31,8 @@ use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; +use Utopia\Agents\Adapters\Ollama; +use Utopia\Agents\Agent; use Utopia\Audit\Adapter\Database as AdapterDatabase; use Utopia\Audit\Audit; use Utopia\Auth\Hashes\Argon2; @@ -160,9 +161,6 @@ Http::setResource('queueForAudits', function (Publisher $publisher) { Http::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); -Http::setResource('queueForExecutions', function (Publisher $publisher) { - return new Execution($publisher); -}, ['publisher']); Http::setResource('eventProcessor', function () { return new EventProcessor(); }, []); @@ -554,7 +552,7 @@ Http::setResource('authorization', function () { return new Authorization(); }, []); -Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Authorization $authorization) { +Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Authorization $authorization, Request $request) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; } @@ -662,7 +660,31 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $dbForProject->getCache()->purge($cacheKey); }; - $usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) { + /** + * Prefix metrics with database type when applicable. + * Avoids prefixing for legacy and tablesdb types to preserve historical metrics. + */ + $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string { + if ( + $databaseType === '' || + $databaseType === DATABASE_TYPE_LEGACY || + $databaseType === DATABASE_TYPE_TABLESDB + ) { + return $metric; + } + + return $databaseType . '.' . $metric; + }; + + // Determine database type from request path, similar to api.php + $path = $request->getURI(); + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; + + $usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) use ($getDatabaseTypePrefixedMetric, $databaseType) { $value = 1; switch ($event) { @@ -694,7 +716,8 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $queueForStatsUsage->addMetric(METRIC_SESSIONS, $value); //per project break; case $document->getCollection() === 'databases': // databases - $queueForStatsUsage->addMetric(METRIC_DATABASES, $value); // per project + $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES); + $queueForStatsUsage->addMetric($metric, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { $queueForStatsUsage->addReduce($document); @@ -703,9 +726,11 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; + $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS); + $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS); $queueForStatsUsage - ->addMetric(METRIC_COLLECTIONS, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $value); + ->addMetric($collectionMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value); if ($event === Database::EVENT_DOCUMENT_DELETE) { $queueForStatsUsage->addReduce($document); @@ -715,10 +740,13 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; $collectionInternalId = $parts[3] ?? 0; + $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS); + $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS); + $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); $queueForStatsUsage - ->addMetric(METRIC_DOCUMENTS, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection break; case $document->getCollection() === 'buckets': //buckets $queueForStatsUsage @@ -776,6 +804,9 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $queueForWebhooks = new Webhook($publisherWebhooks); $queueForRealtime = new Realtime(); + // to get consumed in the stats worker without calling to database + $queueForStatsUsage->setContext('database', new Document(['type' => $databaseType])); + $database ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) @@ -799,7 +830,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor return $database; -}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization']); +}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization', 'request']); Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { @@ -820,6 +851,138 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori return $database; }, ['pools', 'cache', 'authorization']); +Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, StatsUsage $queueForStatsUsage, Authorization $authorization) { + + return function (Document $database) use ($pools, $cache, $project, $request, $queueForStatsUsage, $authorization): Database { + $databaseDSN = $database->getAttribute('database', ''); + $databaseType = $database->getAttribute('type', ''); + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + // for old databases migrated through patch script + // databaseDSN determines the adapter + $databaseDSN = new DSN('mysql://'.$databaseDSN); + } + try { + $dsn = new DSN($project->getAttribute('database')); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $project->getAttribute('database')); + } + + $pool = $pools->get($databaseDSN->getHost()); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setMetadata('host', \gethostname()) + ->setMetadata('project', $project->getId()) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + // inside pools authorization needs to be set first + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant((int)$project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + $timeout = \intval($request->getHeader('x-appwrite-timeout')); + if (!empty($timeout) && Http::isDevelopment()) { + $database->setTimeout($timeout); + } + + // Register database event listeners for usage stats collection + $documentsMetric = METRIC_DOCUMENTS; + $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; + $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $documentsMetric = $databaseType. '.' .$documentsMetric; + $databaseIdDocumentsMetric = $databaseType. '.' .$databaseIdDocumentsMetric; + $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' .$databaseIdCollectionIdDocumentsMetric; + } + $database + ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = 1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $queueForStatsUsage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $queueForStatsUsage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $queueForStatsUsage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1 * $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $queueForStatsUsage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('created', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $queueForStatsUsage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }); + + return $database; + }; + +}, ['pools','cache','project','request','queueForStatsUsage','authorization']); + Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) { $databases = []; @@ -1239,7 +1402,9 @@ Http::setResource('devKey', function (Request $request, Document $project, array $accessedAt = $key->getAttribute('accessedAt', 0); if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { $key->setAttribute('accessedAt', DatabaseDateTime::now()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'accessedAt' => $key->getAttribute('accessedAt') + ]))); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); } @@ -1256,7 +1421,10 @@ Http::setResource('devKey', function (Request $request, Document $project, array /** Update access time as well */ $key->setAttribute('accessedAt', DatabaseDateTime::now()); - $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key)); + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'sdks' => $key->getAttribute('sdks'), + 'accessedAt' => $key->getAttribute('accessedAt') + ]))); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); } } @@ -1413,7 +1581,9 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request, $accessedAt = $token->getAttribute('accessedAt', 0); if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { $token->setAttribute('accessedAt', DatabaseDateTime::now()); - $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token)); + $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([ + 'accessedAt' => $token->getAttribute('accessedAt') + ]))); } return new Document([ @@ -1430,9 +1600,9 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request, return new Document([]); }, ['project', 'dbForProject', 'request', 'authorization']); -Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization) { - return new TransactionState($dbForProject, $authorization); -}, ['dbForProject', 'authorization']); +Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { + return new TransactionState($dbForProject, $authorization, $getDatabasesDB); +}, ['dbForProject', 'authorization', 'getDatabasesDB']); Http::setResource('executionsRetentionCount', function (Document $project, array $plan) { if ($project->getId() === 'console' || empty($plan)) { @@ -1441,3 +1611,10 @@ Http::setResource('executionsRetentionCount', function (Document $project, array return (int) ($plan['executionsRetentionCount'] ?? 100); }, ['project', 'plan']); + +Http::setResource('embeddingAgent', function ($register) { + $adapter = new Ollama(); + $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed')); + $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000')); + return new Agent($adapter); +}, ['register']); diff --git a/app/listeners.php b/app/listeners.php new file mode 100644 index 0000000000..714c255974 --- /dev/null +++ b/app/listeners.php @@ -0,0 +1,9 @@ +onStart(function () use ($stats, $register, $containerId, &$statsDocume ->setAttribute('timestamp', DateTime::now()) ->setAttribute('value', json_encode($payload)); - $database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument)); + $database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), new Document([ + 'timestamp' => $statsDocument->getAttribute('timestamp'), + 'value' => $statsDocument->getAttribute('value') + ]))); } catch (Throwable $th) { logError($th, "updateWorkerDocument"); } @@ -545,20 +555,41 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, } $total = 0; + $outboundBytes = 0; + foreach ($groups as $group) { $data = $event['data']; $data['subscriptions'] = $group['subscriptions']; - $server->send($group['ids'], json_encode([ + $payloadJson = json_encode([ 'type' => 'event', 'data' => $data - ])); - $total += count($group['ids']); + ]); + + $server->send($group['ids'], $payloadJson); + + $count = count($group['ids']); + $total += $count; + $outboundBytes += strlen($payloadJson) * $count; } if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + + $projectId = $event['project'] ?? null; + + if (!empty($projectId)) { + $metrics = [ + METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, + ]; + + if ($outboundBytes > 0) { + $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; + } + + triggerStats($metrics, $projectId); + } } }); } catch (Throwable $th) { @@ -635,6 +666,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } + $rawSize = $request->getSize(); + + triggerStats([ + METRIC_REALTIME_INBOUND => $rawSize, + ], $project->getId()); + /* * Validate Client Domain - Check to avoid CSRF attack. * Adding Appwrite API domains to allow XDOMAIN communication. @@ -689,14 +726,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $user = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT); - $server->send([$connection], json_encode([ + $connectedPayloadJson = json_encode([ 'type' => 'connected', 'data' => [ 'channels' => $names, 'subscriptions' => $mapping, 'user' => $user ] - ])); + ]); + + $server->send([$connection], $connectedPayloadJson); $register->get('telemetry.connectionCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); @@ -707,6 +746,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $stats->incr($project->getId(), 'connections'); $stats->incr($project->getId(), 'connectionsTotal'); + + $connectedOutboundBytes = \strlen($connectedPayloadJson); + + triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId()); + + } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -748,6 +793,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = null; try { + $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -760,7 +806,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $database = getConsoleDB(); $database->setAuthorization($authorization); - if ($projectId !== 'console') { + if (!empty($projectId) && $projectId !== 'console') { $project = $authorization->skip(fn () => $database->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -786,17 +832,41 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many messages.'); } + // Record realtime inbound bytes for this project + if ($project !== null && !$project->isEmpty()) { + triggerStats([ + METRIC_REALTIME_INBOUND => $rawSize, + ], $project->getId()); + } + $message = json_decode($message, true); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) + if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); + } + switch ($message['type']) { case 'ping': - $server->send([$connection], json_encode([ + $pongPayloadJson = json_encode([ 'type' => 'pong' - ])); + ]); + + $server->send([$connection], $pongPayloadJson); + + if ($project !== null && !$project->isEmpty()) { + $pongOutboundBytes = \strlen($pongPayloadJson); + + if ($pongOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $pongOutboundBytes, + ], $project->getId()); + } + } break; case 'authentication': @@ -857,14 +927,27 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $user = $response->output($user, Response::MODEL_ACCOUNT); - $server->send([$connection], json_encode([ + + $authResponsePayloadJson = json_encode([ 'type' => 'response', 'data' => [ 'to' => 'authentication', 'success' => true, 'user' => $user ] - ])); + ]); + + $server->send([$connection], $authResponsePayloadJson); + + if ($project !== null && !$project->isEmpty()) { + $authOutboundBytes = \strlen($authResponsePayloadJson); + + if ($authOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $authOutboundBytes, + ], $project->getId()); + } + } break; @@ -905,6 +988,12 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + + $projectId = $realtime->connections[$connection]['projectId']; + + triggerStats([ + METRIC_REALTIME_CONNECTIONS => -1, + ], $projectId); } $realtime->unsubscribe($connection); diff --git a/app/worker.php b/app/worker.php index 106a2db24a..370b4a185a 100644 --- a/app/worker.php +++ b/app/worker.php @@ -9,7 +9,6 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; -use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -220,6 +219,60 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza }; }, ['pools', 'cache', 'authorization']); +Server::setResource('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) { + return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database { + $projectDocument ??= $project; + $databaseDSN = $database->getAttribute('database', ''); + $databaseType = $database->getAttribute('type', ''); + + // Backwards‑compatibility: older or seeded legacy databases may not have a DSN stored + // in the "database" attribute. In that case, fall back to the project's database DSN. + if ($databaseDSN === '') { + $databaseDSN = $projectDocument->getAttribute('database', ''); + } + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + $databaseDSN = new DSN('mysql://'.$databaseDSN); + } + + try { + $dsn = new DSN($projectDocument->getAttribute('database')); + } catch (\InvalidArgumentException) { + // Temporary fallback until all projects use shared tables + $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database')); + } + + $pools = $register->get('pools'); + $pool = $pools->get($databaseDSN->getHost()); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization); + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables, true)) { + $database + ->setSharedTables(true) + ->setTenant((int) $projectDocument->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $projectDocument->getSequence()); + } + + $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + return $database; + }; +}, ['cache', 'register', 'project', 'authorization']); + Server::setResource('abuseRetention', function () { return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day }); @@ -355,9 +408,6 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); -Server::setResource('queueForExecutions', function (Publisher $publisher) { - return new Execution($publisher); -}, ['publisher']); Server::setResource('queueForRealtime', function () { return new Realtime(); @@ -542,6 +592,10 @@ try { $worker = $platform->getWorker(); +Server::setResource('bus', function ($register) use ($worker) { + return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name)); +}, ['register']); + $worker ->error() ->inject('error') diff --git a/bin/time-travel b/bin/time-travel new file mode 100755 index 0000000000..6aa06601ac --- /dev/null +++ b/bin/time-travel @@ -0,0 +1,3 @@ +#!/bin/sh + +exec php /usr/src/code/app/cli.php time-travel "$@" diff --git a/composer.json b/composer.json index 37f7ad7442..41e0c4bc1c 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "autoload": { "psr-4": { "Appwrite\\": "src/Appwrite", - "Executor\\": "src/Executor" + "Executor\\": "src/Executor", + "Utopia\\Bus\\": "src/Utopia/Bus" } }, "autoload-dev": { @@ -48,7 +49,6 @@ "appwrite/php-runtimes": "0.19.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", - "utopia-php/agents": "1.2.*", "utopia-php/analytics": "0.15.*", "utopia-php/audit": "2.2.*", "utopia-php/auth": "0.5.*", @@ -58,6 +58,7 @@ "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", "utopia-php/database": "5.*", + "utopia-php/agents": "1.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", @@ -69,7 +70,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", - "utopia-php/migration": "dev-feat-platform-db-access as 1.5.0", + "utopia-php/migration": "dev-feat-platform-db-access as 1.8.0", "utopia-php/platform": "0.7.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", @@ -99,6 +100,7 @@ "swoole/ide-helper": "6.*", "phpstan/phpstan": "1.12.*", "textalk/websocket": "1.5.*", + "czproject/git-php": "4.*", "laravel/pint": "1.*", "phpbench/phpbench": "1.*" }, @@ -107,7 +109,6 @@ }, "config": { "platform": { - "php": "8.3" }, "allow-plugins": { "php-http/discovery": true, diff --git a/composer.lock b/composer.lock index 40d2776a8a..41773a9138 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": "44e4887b0166d881da56e99dfcd284a4", + "content-hash": "4ad48ed13cdfd66a288bb1529e731ead", "packages": [ { "name": "adhocore/jwt", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" + "reference": "1010624285470eb60e88ed10035102c75b4ea6af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", + "reference": "1010624285470eb60e88ed10035102c75b4ea6af", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.5" + "source": "https://github.com/symfony/http-client/tree/v7.4.7" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-03-05T11:16:58+00:00" }, { "name": "symfony/http-client-contracts", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.4", + "version": "5.3.8", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ba1ee9cb2c7624d0fada782b285bd9958a07bbe5" + "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ba1ee9cb2c7624d0fada782b285bd9958a07bbe5", - "reference": "ba1ee9cb2c7624d0fada782b285bd9958a07bbe5", + "url": "https://api.github.com/repos/utopia-php/database/zipball/4920bb60afb98d4bd81f4d331765716ae1d40255", + "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255", "shasum": "" }, "require": { @@ -3902,9 +3902,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.4" + "source": "https://github.com/utopia-php/database/tree/5.3.8" }, - "time": "2026-02-24T00:37:36+00:00" + "time": "2026-03-11T01:03:34+00:00" }, { "name": "utopia-php/detector", @@ -4267,16 +4267,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.40", + "version": "0.33.41", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "0ba25e1282c6a2f849053f7ccf28d567c2c321b1" + "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/0ba25e1282c6a2f849053f7ccf28d567c2c321b1", - "reference": "0ba25e1282c6a2f849053f7ccf28d567c2c321b1", + "url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06", + "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06", "shasum": "" }, "require": { @@ -4310,9 +4310,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.40" + "source": "https://github.com/utopia-php/http/tree/0.33.41" }, - "time": "2026-02-19T13:00:08+00:00" + "time": "2026-02-24T12:01:28+00:00" }, { "name": "utopia-php/image", @@ -4521,12 +4521,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "06ea808b3191b985ebaae105c0fe5569094e68b6" + "reference": "4939c67c82c03bfcd7aace9d91cccc6e45fc698c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/06ea808b3191b985ebaae105c0fe5569094e68b6", - "reference": "06ea808b3191b985ebaae105c0fe5569094e68b6", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/4939c67c82c03bfcd7aace9d91cccc6e45fc698c", + "reference": "4939c67c82c03bfcd7aace9d91cccc6e45fc698c", "shasum": "" }, "require": { @@ -4568,7 +4568,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat-platform-db-access" }, - "time": "2026-03-03T16:06:55+00:00" + "time": "2026-03-14T10:41:32+00:00" }, { "name": "utopia-php/mongo", @@ -5215,16 +5215,16 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920" + "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920", - "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/058049326e04a2a0c2f0ce8ad00c7e84825aba14", + "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14", "shasum": "" }, "require": { @@ -5258,9 +5258,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.1" + "source": "https://github.com/utopia-php/vcs/tree/2.0.0" }, - "time": "2026-02-27T12:18:49+00:00" + "time": "2026-02-25T11:36:45+00:00" }, { "name": "utopia-php/websocket", @@ -5438,16 +5438,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.3", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11" + "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/45d22c0107a53bb9a0a4e39db0e738d461631d11", - "reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb", + "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb", "shasum": "" }, "require": { @@ -5483,22 +5483,22 @@ "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.11.3" + "source": "https://github.com/appwrite/sdk-generator/tree/1.11.1" }, - "time": "2026-02-27T06:54:59+00:00" + "time": "2026-02-25T07:15:19+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.1", + "version": "v7.19.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "95b03194f4cdf5c83175ceead673e21cb66465e7" + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/95b03194f4cdf5c83175ceead673e21cb66465e7", - "reference": "95b03194f4cdf5c83175ceead673e21cb66465e7", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", "shasum": "" }, "require": { @@ -5512,7 +5512,7 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "phpunit/phpunit": "^12.5.9 || ^13", "sebastian/environment": "^8.0.3 || ^9", "symfony/console": "^7.4.4 || ^8.0.4", "symfony/process": "^7.4.5 || ^8.0.5" @@ -5522,10 +5522,10 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", "symfony/filesystem": "^7.4.0 || ^8.0.1" }, "bin": [ @@ -5566,7 +5566,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" }, "funding": [ { @@ -5578,7 +5578,71 @@ "type": "paypal" } ], - "time": "2026-02-25T14:53:45+00:00" + "time": "2026-02-06T10:53:26+00:00" + }, + { + "name": "czproject/git-php", + "version": "v4.6.0", + "source": { + "type": "git", + "url": "https://github.com/czproject/git-php.git", + "reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/czproject/git-php/zipball/1f1ecc92aea9ee31120f4f5b759f5aa947420b0a", + "reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.5" + }, + "require-dev": { + "nette/tester": "^2.5" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jan Pecha", + "email": "janpecha@email.cz" + } + ], + "description": "Library for work with Git repository in PHP.", + "keywords": [ + "git" + ], + "support": { + "issues": "https://github.com/czproject/git-php/issues", + "source": "https://github.com/czproject/git-php/tree/v4.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/janpecha", + "type": "github" + }, + { + "url": "https://www.janpecha.cz/donate/git-php/", + "type": "other" + }, + { + "url": "https://donate.stripe.com/7sIcO2a9maTSg2A9AA", + "type": "stripe" + }, + { + "url": "https://thanks.dev/u/gh/czproject", + "type": "thanks.dev" + } + ], + "time": "2025-11-10T07:24:07+00:00" }, { "name": "doctrine/annotations", @@ -9045,8 +9109,8 @@ { "package": "utopia-php/migration", "version": "dev-feat-platform-db-access", - "alias": "1.5.0", - "alias_normalized": "1.5.0.0" + "alias": "1.8.0", + "alias_normalized": "1.8.0.0" } ], "minimum-stability": "dev", @@ -9073,8 +9137,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "platform-overrides": { - "php": "8.3" - }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docker-compose.yml b/docker-compose.yml index bdf77a46b4..0ab317c0e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,6 +104,7 @@ services: - ${_APP_DB_HOST:-mongodb} - redis - coredns + - ollama # - clamav entrypoint: - php @@ -152,7 +153,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -289,6 +295,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -305,7 +312,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_USAGE_STATS - _APP_LOGGING_CONFIG - _APP_LOGGING_CONFIG_REALTIME @@ -325,6 +337,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -340,7 +353,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES @@ -359,6 +371,7 @@ services: - ${_APP_DB_HOST:-mongodb} - request-catcher-sms - request-catcher-webhook + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -371,7 +384,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -390,6 +402,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -399,6 +412,7 @@ services: - appwrite-certificates:/storage/certificates:rw - ./app:/usr/src/code/app - ./src:/usr/src/code/src + environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -414,7 +428,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -456,9 +469,11 @@ services: volumes: - ./app:/usr/src/code/app - ./src:/usr/src/code/src + depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -474,7 +489,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_LOGGING_CONFIG - _APP_WORKERS_NUM - _APP_QUEUE_NAME @@ -496,6 +516,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -513,7 +534,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY @@ -629,6 +649,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -658,7 +679,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES @@ -722,7 +742,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -808,7 +827,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_SMS_FROM - _APP_SMS_PROVIDER @@ -851,6 +869,7 @@ services: - ./app:/usr/src/code/app - ./src:/usr/src/code/src - ./tests:/usr/src/code/tests + depends_on: - ${_APP_DB_HOST:-mongodb} environment: @@ -875,7 +894,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET @@ -918,7 +936,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_MAINTENANCE_INTERVAL - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_CACHE @@ -994,7 +1011,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1028,7 +1044,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1051,6 +1066,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1062,7 +1078,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1085,6 +1100,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1100,7 +1116,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_DATABASE_SHARED_TABLES appwrite-task-scheduler-executions: @@ -1116,6 +1131,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1131,7 +1147,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-messages: entrypoint: schedule-messages @@ -1146,6 +1161,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1161,7 +1177,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_DATABASE_SHARED_TABLES appwrite-assistant: @@ -1238,7 +1253,6 @@ services: start_period: 5s mariadb: - profiles: ["mariadb"] image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p container_name: appwrite-mariadb <<: *x-logging @@ -1257,7 +1271,6 @@ services: command: "mysqld --innodb-flush-method=fsync" mongodb: - profiles: ["mongodb"] image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging @@ -1294,7 +1307,6 @@ services: start_period: 30s appwrite-mongo-express: - profiles: ["mongodb"] image: mongo-express container_name: appwrite-mongo-express networks: @@ -1309,25 +1321,41 @@ services: - mongodb postgresql: - profiles: ["postgresql"] - build: - context: ./tests/resources/postgresql - args: - POSTGRES_VERSION: 17 + image: appwrite/postgres:0.1.0 container_name: appwrite-postgresql <<: *x-logging networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/data:rw + - appwrite-postgresql:/var/lib/postgresql/18/data:rw ports: - "5432:5432" environment: - POSTGRES_DB=${_APP_DB_SCHEMA} - POSTGRES_USER=${_APP_DB_USER} - POSTGRES_PASSWORD=${_APP_DB_PASS} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s command: "postgres" + ollama: + image: appwrite/ollama:0.1.1 + container_name: ollama + ports: + - "11434:11434" + restart: unless-stopped + environment: + MODELS: ${_APP_EMBEDDING_MODELS:-embeddinggemma} + OLLAMA_KEEP_ALIVE: 24h + volumes: + - appwrite-models:/root/.ollama + networks: + - appwrite + redis: image: redis:7.4.7-alpine <<: *x-logging @@ -1567,3 +1595,4 @@ volumes: appwrite-sites: appwrite-builds: appwrite-config: + appwrite-models: \ No newline at end of file diff --git a/docs/references/documentsdb/create-collection.md b/docs/references/documentsdb/create-collection.md new file mode 100644 index 0000000000..c6293a4c38 --- /dev/null +++ b/docs/references/documentsdb/create-collection.md @@ -0,0 +1 @@ +Create a new Collection. Before using this route, you should create a new database resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-document.md b/docs/references/documentsdb/create-document.md new file mode 100644 index 0000000000..197744b4a0 --- /dev/null +++ b/docs/references/documentsdb/create-document.md @@ -0,0 +1 @@ +Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-documents.md b/docs/references/documentsdb/create-documents.md new file mode 100644 index 0000000000..9f4a4a1396 --- /dev/null +++ b/docs/references/documentsdb/create-documents.md @@ -0,0 +1 @@ +Create new Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-index.md b/docs/references/documentsdb/create-index.md new file mode 100644 index 0000000000..164b754161 --- /dev/null +++ b/docs/references/documentsdb/create-index.md @@ -0,0 +1,2 @@ +Creates an index on the attributes listed. Your index should include all the attributes you will query in a single request. +Attributes can be `key`, `fulltext`, and `unique`. \ No newline at end of file diff --git a/docs/references/documentsdb/create.md b/docs/references/documentsdb/create.md new file mode 100644 index 0000000000..b608485341 --- /dev/null +++ b/docs/references/documentsdb/create.md @@ -0,0 +1 @@ +Create a new Database. diff --git a/docs/references/documentsdb/decrement-document-attribute.md b/docs/references/documentsdb/decrement-document-attribute.md new file mode 100644 index 0000000000..b7b32d6148 --- /dev/null +++ b/docs/references/documentsdb/decrement-document-attribute.md @@ -0,0 +1 @@ +Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-collection.md b/docs/references/documentsdb/delete-collection.md new file mode 100644 index 0000000000..90f7aa6aa5 --- /dev/null +++ b/docs/references/documentsdb/delete-collection.md @@ -0,0 +1 @@ +Delete a collection by its unique ID. Only users with write permissions have access to delete this resource. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-document.md b/docs/references/documentsdb/delete-document.md new file mode 100644 index 0000000000..36fbf6802d --- /dev/null +++ b/docs/references/documentsdb/delete-document.md @@ -0,0 +1 @@ +Delete a document by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-documents.md b/docs/references/documentsdb/delete-documents.md new file mode 100644 index 0000000000..a7b05503de --- /dev/null +++ b/docs/references/documentsdb/delete-documents.md @@ -0,0 +1 @@ +Bulk delete documents using queries, if no queries are passed then all documents are deleted. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-index.md b/docs/references/documentsdb/delete-index.md new file mode 100644 index 0000000000..c5b8f49e5f --- /dev/null +++ b/docs/references/documentsdb/delete-index.md @@ -0,0 +1 @@ +Delete an index. \ No newline at end of file diff --git a/docs/references/documentsdb/delete.md b/docs/references/documentsdb/delete.md new file mode 100644 index 0000000000..605fa290d3 --- /dev/null +++ b/docs/references/documentsdb/delete.md @@ -0,0 +1 @@ +Delete a database by its unique ID. Only API keys with with databases.write scope can delete a database. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md new file mode 100644 index 0000000000..8578cef03c --- /dev/null +++ b/docs/references/documentsdb/get-collection-logs.md @@ -0,0 +1 @@ +Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-usage.md b/docs/references/documentsdb/get-collection-usage.md new file mode 100644 index 0000000000..48682a075f --- /dev/null +++ b/docs/references/documentsdb/get-collection-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a collection. Returning the total number of documents. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection.md b/docs/references/documentsdb/get-collection.md new file mode 100644 index 0000000000..97b39e8474 --- /dev/null +++ b/docs/references/documentsdb/get-collection.md @@ -0,0 +1 @@ +Get a collection by its unique ID. This endpoint response returns a JSON object with the collection metadata. \ No newline at end of file diff --git a/docs/references/documentsdb/get-database-usage.md b/docs/references/documentsdb/get-database-usage.md new file mode 100644 index 0000000000..2c2628a464 --- /dev/null +++ b/docs/references/documentsdb/get-database-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a database. You can view the total number of collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md new file mode 100644 index 0000000000..9b96df5ad4 --- /dev/null +++ b/docs/references/documentsdb/get-document-logs.md @@ -0,0 +1 @@ +Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document.md b/docs/references/documentsdb/get-document.md new file mode 100644 index 0000000000..4e4d76bec0 --- /dev/null +++ b/docs/references/documentsdb/get-document.md @@ -0,0 +1 @@ +Get a document by its unique ID. This endpoint response returns a JSON object with the document data. \ No newline at end of file diff --git a/docs/references/documentsdb/get-index.md b/docs/references/documentsdb/get-index.md new file mode 100644 index 0000000000..cdea5b4f27 --- /dev/null +++ b/docs/references/documentsdb/get-index.md @@ -0,0 +1 @@ +Get index by ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-logs.md b/docs/references/documentsdb/get-logs.md new file mode 100644 index 0000000000..8e49da4603 --- /dev/null +++ b/docs/references/documentsdb/get-logs.md @@ -0,0 +1 @@ +Get the database activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get.md b/docs/references/documentsdb/get.md new file mode 100644 index 0000000000..24183f6f6b --- /dev/null +++ b/docs/references/documentsdb/get.md @@ -0,0 +1 @@ +Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/documentsdb/increment-document-attribute.md b/docs/references/documentsdb/increment-document-attribute.md new file mode 100644 index 0000000000..7a19b3fbc7 --- /dev/null +++ b/docs/references/documentsdb/increment-document-attribute.md @@ -0,0 +1 @@ +Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md new file mode 100644 index 0000000000..72ad6d727f --- /dev/null +++ b/docs/references/documentsdb/list-attributes.md @@ -0,0 +1 @@ +List attributes in the collection. \ No newline at end of file diff --git a/docs/references/documentsdb/list-collections.md b/docs/references/documentsdb/list-collections.md new file mode 100644 index 0000000000..e437674915 --- /dev/null +++ b/docs/references/documentsdb/list-collections.md @@ -0,0 +1 @@ +Get a list of all collections that belong to the provided databaseId. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/list-documents.md b/docs/references/documentsdb/list-documents.md new file mode 100644 index 0000000000..4e2ae91792 --- /dev/null +++ b/docs/references/documentsdb/list-documents.md @@ -0,0 +1 @@ +Get a list of all the user's documents in a given collection. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/list-indexes.md b/docs/references/documentsdb/list-indexes.md new file mode 100644 index 0000000000..a8c687fb2b --- /dev/null +++ b/docs/references/documentsdb/list-indexes.md @@ -0,0 +1 @@ +List indexes in the collection. \ No newline at end of file diff --git a/docs/references/documentsdb/list-usage.md b/docs/references/documentsdb/list-usage.md new file mode 100644 index 0000000000..a88e76680e --- /dev/null +++ b/docs/references/documentsdb/list-usage.md @@ -0,0 +1 @@ +List usage metrics and statistics for all databases in the project. You can view the total number of databases, collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/list.md b/docs/references/documentsdb/list.md new file mode 100644 index 0000000000..d93fb9d7a8 --- /dev/null +++ b/docs/references/documentsdb/list.md @@ -0,0 +1 @@ +Get a list of all databases from the current Appwrite project. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/update-collection.md b/docs/references/documentsdb/update-collection.md new file mode 100644 index 0000000000..b8f6bef997 --- /dev/null +++ b/docs/references/documentsdb/update-collection.md @@ -0,0 +1 @@ +Update a collection by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/update-document.md b/docs/references/documentsdb/update-document.md new file mode 100644 index 0000000000..526f3971d1 --- /dev/null +++ b/docs/references/documentsdb/update-document.md @@ -0,0 +1 @@ +Update a document by its unique ID. Using the patch method you can pass only specific fields that will get updated. \ No newline at end of file diff --git a/docs/references/documentsdb/update-documents.md b/docs/references/documentsdb/update-documents.md new file mode 100644 index 0000000000..5f560c6435 --- /dev/null +++ b/docs/references/documentsdb/update-documents.md @@ -0,0 +1 @@ +Update all documents that match your queries, if no queries are submitted then all documents are updated. You can pass only specific fields to be updated. \ No newline at end of file diff --git a/docs/references/documentsdb/update.md b/docs/references/documentsdb/update.md new file mode 100644 index 0000000000..4e99bf2e07 --- /dev/null +++ b/docs/references/documentsdb/update.md @@ -0,0 +1 @@ +Update a database by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/upsert-document.md b/docs/references/documentsdb/upsert-document.md new file mode 100644 index 0000000000..f1b68d13d5 --- /dev/null +++ b/docs/references/documentsdb/upsert-document.md @@ -0,0 +1 @@ +Create or update a Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/upsert-documents.md b/docs/references/documentsdb/upsert-documents.md new file mode 100644 index 0000000000..4feb473076 --- /dev/null +++ b/docs/references/documentsdb/upsert-documents.md @@ -0,0 +1 @@ +Create or update Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh old mode 100755 new mode 100644 diff --git a/mongo-init-replicaset.sh b/mongo-init-replicaset.sh old mode 100755 new mode 100644 diff --git a/mongo-init.js b/mongo-init.js index edff6cc499..bc06ba5b23 100644 --- a/mongo-init.js +++ b/mongo-init.js @@ -15,4 +15,4 @@ adminDb.createUser({ roles: [ { role: 'readWrite', db: database } ] -}); +}); \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon index 153b3be21c..90f28e7539 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,11 @@ parameters: level: 8 paths: + - src/Utopia/Bus + - src/Appwrite/Bus - src/Appwrite/Transformation + bootstrapFiles: + - app/init/constants.php scanDirectories: - vendor/swoole/ide-helper excludePaths: diff --git a/phpunit.xml b/phpunit.xml index e2876bb486..030d89af8d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,7 @@ colors="true" processIsolation="false" stopOnFailure="false" + stopOnError="false" cacheDirectory=".phpunit.cache" > diff --git a/src/Appwrite/Bus/Events/ExecutionCompleted.php b/src/Appwrite/Bus/Events/ExecutionCompleted.php new file mode 100644 index 0000000000..58c84c82f0 --- /dev/null +++ b/src/Appwrite/Bus/Events/ExecutionCompleted.php @@ -0,0 +1,22 @@ + $execution + * @param array $project + * @param array $spec + * @param array $resource + */ + public function __construct( + public readonly array $execution, + public readonly array $project, + public readonly array $spec = [], + public readonly array $resource = [], + ) { + } +} diff --git a/src/Appwrite/Bus/Events/RequestCompleted.php b/src/Appwrite/Bus/Events/RequestCompleted.php new file mode 100644 index 0000000000..d6f9f1c90b --- /dev/null +++ b/src/Appwrite/Bus/Events/RequestCompleted.php @@ -0,0 +1,22 @@ + $project + * @param array $deployment + */ + public function __construct( + public readonly array $project, + public readonly Request $request, + public readonly Response $response, + public readonly array $deployment = [], + ) { + } +} diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php new file mode 100644 index 0000000000..9bd539d5fe --- /dev/null +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -0,0 +1,39 @@ +desc('Persists execution logs to database via queue') + ->inject('publisher') + ->callback($this->handle(...)); + } + + public function handle(ExecutionCompleted $event, Publisher $publisher): void + { + $queueForExecutions = new Execution($publisher); + $queueForExecutions + ->setExecution(new Document($event->execution)) + ->setProject(new Document($event->project)) + ->trigger(); + } +} diff --git a/src/Appwrite/Bus/Listeners/Usage.php b/src/Appwrite/Bus/Listeners/Usage.php new file mode 100644 index 0000000000..219287033d --- /dev/null +++ b/src/Appwrite/Bus/Listeners/Usage.php @@ -0,0 +1,114 @@ +desc('Records usage metrics') + ->inject('publisherStatsUsage') + ->callback($this->handle(...)); + } + + public function handle(Event $event, Publisher $publisher): void + { + match (true) { + $event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisher), + $event instanceof RequestCompleted => $this->handleRequestCompleted($event, $publisher), + default => null, + }; + } + + private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisher): void + { + $execution = new Document($event->execution); + $resource = new Document($event->resource); + + // Non-SSR sites don't record execution metrics + if ($execution->getAttribute('resourceType') === 'sites' && $resource->getAttribute('adapter') !== 'ssr') { + return; + } + $project = new Document($event->project); + $spec = $event->spec; + + $resourceType = $execution->getAttribute('resourceType', ''); + $resourceInternalId = $execution->getAttribute('resourceInternalId', ''); + $duration = $execution->getAttribute('duration', 0); + + $compute = (int)($duration * 1000); + $mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $duration * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT)); + + $queueForStatsUsage = new StatsUsage($publisher); + $queueForStatsUsage + ->setProject($project) + ->addMetric(METRIC_EXECUTIONS, 1) + ->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS), 1) + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1) + ->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute) + ->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE), $compute) + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), $compute) + ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds) + ->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), $mbSeconds) + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds) + ->trigger(); + } + + private function handleRequestCompleted(RequestCompleted $event, Publisher $publisher): void + { + $fileSize = 0; + $file = $event->request->getFiles('file'); + if (!empty($file)) { + $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; + } + + $project = new Document($event->project); + $deployment = new Document($event->deployment); + $queueForStatsUsage = new StatsUsage($publisher); + + $inbound = $event->request->getSize() + $fileSize; + $outbound = $event->response->getSize(); + + $queueForStatsUsage->setProject($project); + + if ($deployment->getAttribute('resourceType') === 'sites') { + $siteInternalId = $deployment->getAttribute('resourceInternalId', ''); + $queueForStatsUsage + ->addMetric(METRIC_SITES_REQUESTS, 1) + ->addMetric(METRIC_SITES_INBOUND, $inbound) + ->addMetric(METRIC_SITES_OUTBOUND, $outbound) + ->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_REQUESTS), 1) + ->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_INBOUND), $inbound) + ->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_OUTBOUND), $outbound); + } else { + $queueForStatsUsage + ->addMetric(METRIC_NETWORK_REQUESTS, 1) + ->addMetric(METRIC_NETWORK_INBOUND, $inbound) + ->addMetric(METRIC_NETWORK_OUTBOUND, $outbound); + } + + $queueForStatsUsage->trigger(); + } +} diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index 8e098774e6..71bd8799c7 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -21,17 +21,23 @@ class TransactionState { private Database $dbForProject; private Authorization $authorization; - /** @var Authorization $authorization */ - public function __construct(Database $dbForProject, Authorization $authorization) + /** + * @var callable(Document $database): Database + */ + private mixed $getDatabasesDB; + + public function __construct(Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { $this->dbForProject = $dbForProject; $this->authorization = $authorization; + $this->getDatabasesDB = $getDatabasesDB; } /** * Get a document with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string $documentId Document ID * @param string|null $transactionId Optional transaction ID @@ -42,13 +48,15 @@ class TransactionState * @throws Timeout */ public function getDocument( + Document $database, string $collectionId, string $documentId, ?string $transactionId = null, array $queries = [] ): Document { + $dbForDatabases = ($this->getDatabasesDB)($database); if ($transactionId === null) { - return $this->dbForProject->getDocument($collectionId, $documentId, $queries); + return $dbForDatabases->getDocument($collectionId, $documentId, $queries); } $state = $this->getTransactionState($transactionId); @@ -66,7 +74,7 @@ class TransactionState if ($docState['action'] === 'update' || $docState['action'] === 'upsert') { // Merge with committed version - $committedDoc = $this->dbForProject->getDocument($collectionId, $documentId, $queries); + $committedDoc = $dbForDatabases->getDocument($collectionId, $documentId, $queries); if (!$committedDoc->isEmpty()) { foreach ($docState['document']->getAttributes() as $key => $value) { if ($key !== '$id') { @@ -80,13 +88,13 @@ class TransactionState } } } - - return $this->dbForProject->getDocument($collectionId, $documentId, $queries); + return $dbForDatabases->getDocument($collectionId, $documentId, $queries); } /** * List documents with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string|null $transactionId Optional transaction ID * @param array $queries Optional query filters @@ -96,17 +104,19 @@ class TransactionState * @throws Timeout */ public function listDocuments( + Document $database, string $collectionId, ?string $transactionId = null, array $queries = [] ): array { + $dbForDatabases = ($this->getDatabasesDB)($database); // If no transaction, use normal database retrieval if ($transactionId === null) { - return $this->dbForProject->find($collectionId, $queries); + return $dbForDatabases->find($collectionId, $queries); } $state = $this->getTransactionState($transactionId); - $committedDocs = $this->dbForProject->find($collectionId, $queries); + $committedDocs = $dbForDatabases->find($collectionId, $queries); $documentMap = []; // Build map of committed documents @@ -147,6 +157,7 @@ class TransactionState /** * Count documents with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string|null $transactionId Optional transaction ID * @param array $queries Optional query filters @@ -156,23 +167,23 @@ class TransactionState * @throws Timeout */ public function countDocuments( + Document $database, string $collectionId, ?string $transactionId = null, array $queries = [] ): int { + $dbForDatabases = ($this->getDatabasesDB)($database); if ($transactionId === null) { - return $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + return $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT); } $state = $this->getTransactionState($transactionId); - - $baseCount = $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + $baseCount = $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT); if (!isset($state[$collectionId])) { return $baseCount; } - - $committedDocs = $this->dbForProject->find($collectionId, $queries); + $committedDocs = $dbForDatabases->find($collectionId, $queries); $committedDocIds = []; foreach ($committedDocs as $doc) { $committedDocIds[$doc->getId()] = true; @@ -214,17 +225,19 @@ class TransactionState /** * Check if a document exists with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string $documentId Document ID * @param string|null $transactionId Optional transaction ID * @return bool True if document exists */ public function documentExists( + Document $database, string $collectionId, string $documentId, ?string $transactionId = null ): bool { - $doc = $this->getDocument($collectionId, $documentId, $transactionId); + $doc = $this->getDocument($database, $collectionId, $documentId, $transactionId); return !$doc->isEmpty(); } diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index ba633b4478..bf6339f8a0 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -519,6 +519,7 @@ class Event * @param string $pattern * @param array $params * @param ?Document $database + * @param ?Document $database * @return array * @throws \InvalidArgumentException */ @@ -533,7 +534,7 @@ class Event $parsed = self::parseEventPattern($pattern); // to switch the resource types from databases to the required prefix // eg; all databases events get fired with databases. prefix which mainly depicts legacy type - // so a projection from databases to the actual prefix + // so a projection from databases to the actual prefix(documentsdb, vectorsdb,etc) if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) { $parsed = self::getDatabaseTypeEvents($database, $parsed); } @@ -695,7 +696,6 @@ class Event ) ) { $pairedEvents = []; - foreach ($events as $event) { $pairedEvents[] = $event; // tablesdb needs databases event with tables and collections @@ -745,6 +745,13 @@ class Event 'attributes' => 'columns', ]; break; + case 'documentsdb': + case 'vectorsdb': + // sending the type itself(eg: documentsdb, vectorsdb) + $eventMap = [ + 'databases' => $database->getAttribute('type') + ]; + break; } foreach ($event as $eventKey => $eventValue) { if (isset($eventMap[$eventValue])) { diff --git a/src/Appwrite/Event/StatsUsage.php b/src/Appwrite/Event/StatsUsage.php index a944d70c94..c0d6bd845a 100644 --- a/src/Appwrite/Event/StatsUsage.php +++ b/src/Appwrite/Event/StatsUsage.php @@ -84,6 +84,7 @@ class StatsUsage extends Event } return true; }), + 'context' => $this->context, ]; } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 95a9c6ddac..4370078592 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -339,6 +339,7 @@ class Exception extends \Exception public const string MIGRATION_ALREADY_EXISTS = 'migration_already_exists'; 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'; /** Realtime */ public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid'; diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 85ae4fde25..7a2b6fe19a 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -492,6 +492,8 @@ class Realtime extends MessagingAdapter break; case 'databases': case 'tablesdb': + case 'documentsdb': + case 'vectorsdb': $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; @@ -511,12 +513,20 @@ class Realtime extends MessagingAdapter $resourceId = $tableId ?: $collectionId; $channels = []; - // sending legacy + tablesdb events to both legacy and tablesdb - $channels = array_values(array_unique(array_merge( - self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'), - self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'), - self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId()) - ))); + switch ($parts[0]) { + case 'databases': + case 'tablesdb': + // sending legacy + tablesdb events to both legacy and tablesdb + $channels = array_values(array_unique(array_merge( + self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'), + self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'), + self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId()) + ))); + break; + default: + // only prefixed events + $channels = array_values(self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId())); + } $roles = $collection->getAttribute('documentSecurity', false) ? \array_merge($collection->getRead(), $payload->getRead()) @@ -582,6 +592,7 @@ class Realtime extends MessagingAdapter * @param string $resourceId The collection/table ID * @param string $payloadId The document/row ID * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) * @return array Array of channel names */ private static function getDatabaseChannels( @@ -615,6 +626,13 @@ class Realtime extends MessagingAdapter $channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}"; break; + case 'documentsdb': + case 'vectorsdb': + $channels[] = 'documents'; + $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents"; + $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}"; + break; + default: $basePrefix = 'databases'; $channels[] = 'documents'; @@ -623,6 +641,7 @@ class Realtime extends MessagingAdapter break; } + return $channels; } } diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php index bd961ffbc2..f7226d915e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Update.php @@ -119,7 +119,7 @@ class Update extends Action $authenticator->setAttribute('verified', true); - $dbForProject->updateDocument('authenticators', $authenticator->getId(), $authenticator); + $dbForProject->updateDocument('authenticators', $authenticator->getId(), new Document(['verified' => true])); $dbForProject->purgeCachedDocument('users', $user->getId()); $factors = $session->getAttribute('factors', []); @@ -127,7 +127,7 @@ class Update extends Action $factors = \array_values(\array_unique($factors)); $session->setAttribute('factors', $factors); - $dbForProject->updateDocument('sessions', $session->getId(), $session); + $dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors])); $queueForEvents->setParam('userId', $user->getId()); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php index 3f3532cf16..5f6b7a1186 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php @@ -117,7 +117,7 @@ class Update extends Action $mfaRecoveryCodes = \array_diff($mfaRecoveryCodes, [$otp]); $mfaRecoveryCodes = \array_values($mfaRecoveryCodes); $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes])); return true; } @@ -147,11 +147,13 @@ class Update extends Action $factors[] = $type; $factors = \array_values(\array_unique($factors)); + $mfaUpdatedAt = DateTime::now(); + $session ->setAttribute('factors', $factors) - ->setAttribute('mfaUpdatedAt', DateTime::now()); + ->setAttribute('mfaUpdatedAt', $mfaUpdatedAt); - $dbForProject->updateDocument('sessions', $session->getId(), $session); + $dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors, 'mfaUpdatedAt' => $mfaUpdatedAt])); $queueForEvents ->setParam('userId', $user->getId()) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php index 969cf9c262..0d1191aaea 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Create.php @@ -93,7 +93,7 @@ class Create extends Action $mfaRecoveryCodes = Type::generateBackupCodes(); $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes])); $queueForEvents->setParam('userId', $user->getId()); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php index 9b9f9b7e00..40051cfebc 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/RecoveryCodes/Update.php @@ -91,7 +91,7 @@ class Update extends Action $mfaRecoveryCodes = Type::generateBackupCodes(); $user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes); - $dbForProject->updateDocument('users', $user->getId(), $user); + $dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes])); $queueForEvents->setParam('userId', $user->getId()); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php index 227062caa3..a578149654 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php @@ -72,7 +72,7 @@ class Update extends Action ): void { $user->setAttribute('mfa', $mfa); - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['mfa' => $mfa])); if ($mfa) { $factors = $session->getAttribute('factors', []); @@ -89,7 +89,7 @@ class Update extends Action $factors = \array_values(\array_unique($factors)); $session->setAttribute('factors', $factors); - $dbForProject->updateDocument('sessions', $session->getId(), $session); + $dbForProject->updateDocument('sessions', $session->getId(), new Document(['factors' => $factors])); } $queueForEvents->setParam('userId', $user->getId()); diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 08b4bbced8..f388e46f83 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -143,7 +143,12 @@ class Base extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); $queueForBuilds ->setType(BUILD_TYPE_DEPLOYMENT) @@ -249,7 +254,12 @@ class Base extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); $sitesDomain = $platform['sitesDomain']; $domain = ID::unique() . "." . $sitesDomain; diff --git a/src/Appwrite/Platform/Modules/Databases/Constants.php b/src/Appwrite/Platform/Modules/Databases/Constants.php index cfc297c3f4..edc6b09cf0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Constants.php +++ b/src/Appwrite/Platform/Modules/Databases/Constants.php @@ -22,3 +22,11 @@ const INDEX = 'index'; const DOCUMENTS = 'document'; const ATTRIBUTES = 'attribute'; const COLLECTIONS = 'collection'; + +const LEGACY = 'legacy'; +const TABLESDB = 'tablesdb'; +const DOCUMENTSDB = 'documentsdb'; +const VECTORSDB = 'vectorsdb'; + +const MIN_VECTOR_DIMENSION = 1; +const MAX_VECTOR_DIMENSION = 16000; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index 728e732cc5..b2417871ed 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -10,7 +10,7 @@ use Utopia\Database\Operator; class Action extends AppwriteAction { - private string $context = 'legacy'; + private string $context = DATABASE_TYPE_LEGACY; public function getDatabaseType(): string { @@ -20,7 +20,13 @@ class Action extends AppwriteAction public function setHttpPath(string $path): AppwriteAction { if (\str_contains($path, '/tablesdb')) { - $this->context = 'tablesdb'; + $this->context = DATABASE_TYPE_TABLESDB; + } + if (\str_contains($path, '/documentsdb')) { + $this->context = DATABASE_TYPE_DOCUMENTSDB; + } + if (\str_contains($path, '/vectorsdb')) { + $this->context = DATABASE_TYPE_VECTORSDB; } return parent::setHttpPath($path); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index f49d07ec4c..2f541936a8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -15,6 +15,8 @@ abstract class Action extends UtopiaAction */ private ?string $context = COLLECTIONS; + private ?string $databaseType = LEGACY; + /** * Get the response model used in the SDK and HTTP responses. */ @@ -24,6 +26,9 @@ abstract class Action extends UtopiaAction { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; + $this->databaseType = TABLESDB; + } elseif (\str_contains($path, '/vectorsdb')) { + $this->databaseType = VECTORSDB; } return parent::setHttpPath($path); } @@ -36,6 +41,14 @@ abstract class Action extends UtopiaAction return $this->context; } + /** + * Get the current API database type. + */ + protected function getDatabaseType(): string + { + return $this->databaseType; + } + /** * Get the key used in event parameters (e.g., 'collectionId' or 'tableId'). */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php index f860fc68af..38b96e67bc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php @@ -12,6 +12,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\IndexDependency as IndexDependencyValidator; use Utopia\Database\Validator\Key; @@ -98,7 +99,8 @@ class Delete extends Action } if ($attribute->getAttribute('status') === 'available') { - $attribute = $dbForProject->updateDocument('attributes', $attribute->getId(), $attribute->setAttribute('status', 'deleting')); + $attribute->setAttribute('status', 'deleting'); + $attribute = $dbForProject->updateDocument('attributes', $attribute->getId(), new Document(['status' => 'deleting'])); } $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); @@ -118,7 +120,8 @@ class Delete extends Action } if ($relatedAttribute->getAttribute('status') === 'available') { - $dbForProject->updateDocument('attributes', $relatedAttribute->getId(), $relatedAttribute->setAttribute('status', 'deleting')); + $relatedAttribute->setAttribute('status', 'deleting'); + $dbForProject->updateDocument('attributes', $relatedAttribute->getId(), new Document(['status' => 'deleting'])); } $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $options['relatedCollection']); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php index 216ec07e05..fd309a413c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php @@ -84,12 +84,13 @@ class Create extends Action ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -121,12 +122,18 @@ class Create extends Action throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); } + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($database); + $collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $databaseKey = 'database_' . $database->getSequence(); $attributesValidator = new AttributesValidator( APP_LIMIT_ARRAY_PARAMS_SIZE, - $dbForProject->getAdapter()->getSupportForSpatialAttributes() + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForAttributes() ); if (!$attributesValidator->isValid($attributes)) { @@ -155,7 +162,7 @@ class Create extends Action } // Validate indexes - $indexesValidator = new IndexesValidator($dbForProject->getLimitForIndexes()); + $indexesValidator = new IndexesValidator($dbForDatabases->getLimitForIndexes()); if (!$indexesValidator->isValid($indexes)) { $dbForProject->deleteDocument($databaseKey, $collection->getId()); throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $indexesValidator->getDescription()); @@ -178,21 +185,23 @@ class Create extends Action $indexValidator = new IndexValidator( $collectionAttributes, [], - $dbForProject->getAdapter()->getMaxIndexLength(), - $dbForProject->getAdapter()->getInternalIndexesKeys(), - $dbForProject->getAdapter()->getSupportForIndexArray(), - $dbForProject->getAdapter()->getSupportForSpatialIndexNull(), - $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForProject->getAdapter()->getSupportForVectors(), - $dbForProject->getAdapter()->getSupportForAttributes(), - $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForProject->getAdapter()->getSupportForIdenticalIndexes(), - $dbForProject->getAdapter()->getSupportForObjectIndexes(), - $dbForProject->getAdapter()->getSupportForTrigramIndex(), - $dbForProject->getAdapter()->getSupportForSpatialAttributes(), - $dbForProject->getAdapter()->getSupportForIndex(), - $dbForProject->getAdapter()->getSupportForUniqueIndex(), - $dbForProject->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject(), ); foreach ($collectionIndexes as $indexDoc) { @@ -203,7 +212,7 @@ class Create extends Action } try { - $dbForProject->createCollection( + $dbForDatabases->createCollection( id: $collectionKey, attributes: $collectionAttributes, indexes: $collectionIndexes, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php index 7f194aa93d..7a5b73f7db 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php @@ -62,13 +62,14 @@ class Delete extends Action ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -85,7 +86,8 @@ class Delete extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, "Failed to remove $type from DB"); } - $dbForProject->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); $queueForDatabase ->setType(DATABASE_TYPE_DELETE_COLLECTION) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 39146508fb..0bd4a2e080 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -17,6 +17,7 @@ abstract class Action extends DatabasesAction * @var string|null The current context (either 'row' or 'document') */ private ?string $context = DOCUMENTS; + private ?string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. @@ -27,6 +28,10 @@ abstract class Action extends DatabasesAction { if (str_contains($path, '/tablesdb/')) { $this->context = ROWS; + } elseif (str_contains($path, '/documentsdb/')) { + $this->databaseType = DATABASE_TYPE_DOCUMENTSDB; + } elseif (str_contains($path, '/vectorsdb/')) { + $this->databaseType = DATABASE_TYPE_VECTORSDB; } $contextId = '$' . $this->getCollectionsEventsContext() . 'Id'; @@ -45,6 +50,39 @@ abstract class Action extends DatabasesAction return parent::setHttpPath($path); } + protected function getDatabasesOperationReadMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASES_OPERATIONS_READS; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_READS; + } + + protected function getDatabasesIdOperationReadMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_READS; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_READS; + } + + protected function getDatabasesOperationWriteMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASES_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES; + + } + + protected function getDatabasesIdOperationWriteMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + /** * Get the plural of the given name. * diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 85813b2354..33d5c39182 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -82,6 +82,7 @@ class Decrement extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('plan') @@ -89,7 +90,7 @@ class Decrement extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -170,8 +171,9 @@ class Decrement extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { - $document = $dbForProject->decreaseDocumentAttribute( + $document = $dbForDatabases->decreaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), id: $documentId, attribute: $attribute, @@ -201,8 +203,8 @@ class Decrement extends Action ); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + ->addMetric($this->getDatabasesOperationWriteMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); $queueForEvents ->setParam('databaseId', $databaseId) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 031b5abcc6..a418fe6c36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -82,6 +82,7 @@ class Increment extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('plan') @@ -89,7 +90,7 @@ class Increment extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -170,8 +171,9 @@ class Increment extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { - $document = $dbForProject->increaseDocumentAttribute( + $document = $dbForDatabases->increaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), id: $documentId, attribute: $attribute, @@ -201,8 +203,8 @@ class Increment extends Action ); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + ->addMetric($this->getDatabasesOperationWriteMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); $queueForEvents ->setParam('databaseId', $databaseId) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php index 6ab67318c7..4db99d4447 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php @@ -76,6 +76,7 @@ class Delete extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -86,7 +87,7 @@ class Delete extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -163,10 +164,11 @@ class Delete extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $documents = []; try { - $modified = $dbForProject->deleteDocuments( + $modified = $dbForDatabases->deleteDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $queries, onNext: function (Document $document) use ($plan, &$documents) { @@ -189,12 +191,12 @@ class Delete extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, - $this->getSDKGroup() => $documents, + $this->getSDKGroup() => $documents ]), $this->getResponseModel()); $this->triggerBulk( diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php index d306414a89..fd3697bc76 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php @@ -80,6 +80,7 @@ class Update extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -90,7 +91,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -189,11 +190,12 @@ class Update extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $documents = []; try { - $modified = $dbForProject->withPreserveDates(function () use ($plan, &$documents, $dbForProject, $database, $collection, $data, $queries) { - return $dbForProject->updateDocuments( + $modified = $dbForDatabases->withPreserveDates(function () use ($plan, &$documents, $dbForDatabases, $database, $collection, $data, $queries) { + return $dbForDatabases->updateDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), new Document($data), $queries, @@ -220,8 +222,8 @@ class Update extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php index cf30fee173..13fb5e4c6a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php @@ -58,7 +58,7 @@ class Upsert extends Action group: $this->getSDKGroup(), name: self::getName(), description: '/docs/references/databases/upsert-documents.md', - auth: [AuthType::ADMIN, AuthType::KEY], + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, @@ -78,6 +78,7 @@ class Upsert extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -88,7 +89,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -165,11 +166,12 @@ class Upsert extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $upserted = []; try { - $modified = $dbForProject->withPreserveDates(function () use ($dbForProject, $database, $collection, $documents, $plan, &$upserted) { - return $dbForProject->upsertDocuments( + $modified = $dbForDatabases->withPreserveDates(function () use ($dbForDatabases, $database, $collection, $documents, $plan, &$upserted) { + return $dbForDatabases->upsertDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documents, onNext: function (Document $document) use ($plan, &$upserted) { @@ -195,8 +197,8 @@ class Upsert extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 9b14122abf..4960273631 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -85,7 +85,7 @@ class Create extends Action new Parameter('documentId', optional: false), new Parameter('data', optional: false), new Parameter('permissions', optional: true), - new Parameter('transactionId', optional: true), + new Parameter('transactionId', optional: true) ], deprecated: new Deprecated( since: '1.8.0', @@ -110,7 +110,7 @@ class Create extends Action new Parameter('databaseId', optional: false), new Parameter('collectionId', optional: false), new Parameter('documents', optional: false), - new Parameter('transactionId', optional: true), + new Parameter('transactionId', optional: true) ], deprecated: new Deprecated( since: '1.8.0', @@ -127,6 +127,7 @@ class Create extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('queueForEvents') ->inject('queueForStatsUsage') @@ -138,7 +139,7 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } - public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -447,11 +448,12 @@ class Create extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { $created = []; - $dbForProject->withPreserveDates( - function () use (&$created, $dbForProject, $database, $collection, $documents) { - $dbForProject->createDocuments( + $dbForDatabases->withPreserveDates( + function () use (&$created, $dbForDatabases, $database, $collection, $documents) { + $dbForDatabases->createDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documents, onNext: function ($doc) use (&$created) { @@ -490,15 +492,15 @@ class Create extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); // per collection + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // per collection $response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED); if ($isBulk) { $response->dynamic(new Document([ 'total' => count($created), - $this->getSdkGroup() => $created + $this->getSDKGroup() => $created ]), $this->getBulkResponseModel()); $this->triggerBulk( diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 3171fe7aaf..bfe83d1a6a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -79,6 +79,7 @@ class Delete extends Action ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') @@ -95,6 +96,7 @@ class Delete extends Action ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, + callable $getDatabasesDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, @@ -116,14 +118,15 @@ class Delete extends Action throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } + $dbForDatabases = $getDatabasesDB($database); // Read permission should not be required for delete $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($document->isEmpty()) { @@ -187,8 +190,8 @@ class Delete extends Action } try { - $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $collection, $documentId) { - $dbForProject->deleteDocument( + $dbForDatabases->withRequestTimestamp($requestTimestamp, function () use ($dbForDatabases, $database, $collection, $documentId) { + $dbForDatabases->deleteDocument( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId ); @@ -211,8 +214,8 @@ class Delete extends Action ); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); // per collection + ->addMetric($this->getDatabasesOperationWriteMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); // per collection $response->addHeader('X-Debug-Operations', 1); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 515b7029e6..70a5c84462 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -68,13 +68,14 @@ class Get extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -86,6 +87,7 @@ class Get extends Action $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + $dbForDatabases = $getDatabasesDB($database); if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } @@ -99,16 +101,17 @@ class Get extends Action try { $selects = Query::groupByType($queries)['selections'] ?? []; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId, $queries); } elseif (! empty($selects)) { // has selects, allow relationship on documents! - $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries); + $document = $dbForDatabases->getDocument($collectionTableId, $documentId, $queries); } else { // has no selects, disable relationship looping on documents! - $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries)); + $document = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId, $queries)); } } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); @@ -131,8 +134,8 @@ class Get extends Action ); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); $response->addHeader('X-Debug-Operations', $operations); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php index 2e838329cb..4588e3666b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php @@ -70,6 +70,7 @@ class XList extends Action ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('locale') ->inject('geodb') ->inject('authorization') @@ -77,7 +78,7 @@ class XList extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -89,7 +90,8 @@ class XList extends Action throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } - $document = $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId); + $dbForDatabases = $getDatabasesDB($database); + $document = $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId); if ($document->isEmpty()) { throw new Exception($this->getNotFoundException(), params: [$documentId]); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index 06eca79dad..e1cc2b03cd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -83,6 +83,7 @@ class Update extends Action ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') @@ -91,7 +92,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -118,15 +119,16 @@ class Update extends Action $data = $this->parseOperators($data, $collection); } + $dbForDatabases = $getDatabasesDB($database); // Read permission should not be required for update /** @var Document $document */ $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($document->isEmpty()) { @@ -247,8 +249,8 @@ class Update extends Action $setCollection($collection, $newDocument); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); + ->addMetric($this->getDatabasesOperationWriteMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), $operations); // Handle transaction staging if ($transactionId !== null) { @@ -319,9 +321,9 @@ class Update extends Action try { - $document = $dbForProject->withRequestTimestamp( + $document = $dbForDatabases->withRequestTimestamp( $requestTimestamp, - fn () => $dbForProject->withPreserveDates(fn () => $dbForProject->updateDocument( + fn () => $dbForDatabases->withPreserveDates(fn () => $dbForDatabases->updateDocument( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $document->getId(), $newDocument diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef76ebe7cd..b819931f86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -87,6 +87,7 @@ class Upsert extends Action ->inject('response') ->inject('user') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') @@ -95,7 +96,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -124,6 +125,7 @@ class Upsert extends Action $data = $this->parseOperators($data, $collection); } + $dbForDatabases = $getDatabasesDB($database); $allowedPermissions = [ Database::PERMISSION_READ, Database::PERMISSION_UPDATE, @@ -134,13 +136,15 @@ class Upsert extends Action $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + // If no permission, upsert permission from the old document if present (update scenario) else add default permission (create scenario) if (\is_null($permissions)) { if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $oldDocument = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $oldDocument = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($oldDocument->isEmpty()) { if (!empty($user->getId())) { @@ -182,7 +186,7 @@ class Upsert extends Action $newDocument = new Document($data); $operations = 0; - $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $database, &$operations, $authorization) { + $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $dbForDatabases, $database, &$operations, $authorization) { $operations++; $relationships = \array_filter( @@ -226,7 +230,7 @@ class Upsert extends Action if ($relation instanceof Document) { $relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser); - $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument( + $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument( 'database_' . $database->getSequence() . '_collection_' . $relatedCollection->getSequence(), $relation->getId() )); @@ -257,8 +261,8 @@ class Upsert extends Action $setCollection($collection, $newDocument); $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // Handle transaction staging if ($transactionId !== null) { @@ -327,8 +331,8 @@ class Upsert extends Action $upserted = []; try { - $dbForProject->withPreserveDates(function () use (&$upserted, $dbForProject, $database, $collection, $newDocument) { - return $dbForProject->upsertDocuments( + $dbForDatabases->withPreserveDates(function () use (&$upserted, $dbForDatabases, $database, $collection, $newDocument) { + return $dbForDatabases->upsertDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), [$newDocument], onNext: function (Document $document) use (&$upserted) { @@ -351,9 +355,9 @@ class Upsert extends Action if (empty($upserted[0])) { if ($transactionId !== null) { // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $upserted[0] = $dbForProject->getDocument($collectionTableId, $documentId); + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 0c93eaf105..ef8de7ba57 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -75,13 +75,14 @@ class XList extends Action ->inject('response') ->inject('dbForProject') ->inject('user') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -102,6 +103,7 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + $dbForDatabases = $getDatabasesDB($database); $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -113,7 +115,7 @@ class XList extends Action $documentId = $cursor->getValue(); - $cursorDocument = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); if ($cursorDocument->isEmpty()) { $type = ucfirst($this->getContext()); @@ -126,11 +128,10 @@ class XList extends Action try { $selectQueries = Query::groupByType($queries)['selections'] ?? []; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); - // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries); - $total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0; + $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); + $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif (! empty($selectQueries)) { if ((int)$ttl > 0) { @@ -169,7 +170,7 @@ class XList extends Action }, $cachedDocuments); $documentsCacheHit = true; } else { - $documents = $dbForProject->find($collectionTableId, $queries); + $documents = $dbForDatabases->find($collectionTableId, $queries); // Convert Document objects to arrays for caching $documentsArray = \array_map(function ($doc) { @@ -195,15 +196,15 @@ class XList extends Action } else { // has selects, allow relationship on documents - $documents = $dbForProject->find($collectionTableId, $queries); - $total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $dbForDatabases->find($collectionTableId, $queries); + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } else { // has no selects, disable relationship loading on documents /* @type Document[] $documents */ - $documents = $dbForProject->skipRelationships(fn () => $dbForProject->find($collectionTableId, $queries)); - $total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -229,8 +230,8 @@ class XList extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); $response->dynamic(new Document([ 'total' => $total, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php index fd785f3609..7e073c95d4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php @@ -77,13 +77,14 @@ class Create extends Action ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -103,7 +104,9 @@ class Create extends Action Query::equal('databaseInternalId', [$db->getSequence()]) ], 61); - $limit = $dbForProject->getLimitForIndexes(); + $dbForDatabases = $getDatabasesDB($db); + + $limit = $dbForDatabases->getLimitForIndexes(); if ($count >= $limit) { throw new Exception($this->getLimitException(), params: [$collectionId]); @@ -145,32 +148,35 @@ class Create extends Action ]; $contextType = $this->getParentContext(); - foreach ($attributes as $i => $attribute) { - $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); + if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { + foreach ($attributes as $i => $attribute) { + // find attribute metadata in collection document + $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); - if ($attributeIndex === false) { - throw new Exception($this->getParentUnknownException(), params: [$attribute]); - } + if ($attributeIndex === false) { + throw new Exception($this->getParentUnknownException(), params: [$attribute]); + } - $attributeStatus = $oldAttributes[$attributeIndex]['status']; - $attributeType = $oldAttributes[$attributeIndex]['type']; - $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; + $attributeStatus = $oldAttributes[$attributeIndex]['status']; + $attributeType = $oldAttributes[$attributeIndex]['type']; + $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; - if ($attributeType === Database::VAR_RELATIONSHIP) { - throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); - } + if ($attributeType === Database::VAR_RELATIONSHIP) { + throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); + } - if ($attributeStatus !== 'available') { - throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); - } + if ($attributeStatus !== 'available') { + throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); + } - if (empty($lengths[$i])) { - $lengths[$i] = null; - } + if (empty($lengths[$i])) { + $lengths[$i] = null; + } - if ($attributeArray === true) { - // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. - throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + if ($attributeArray === true) { + // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. + throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + } } } @@ -191,21 +197,23 @@ class Create extends Action $validator = new IndexValidator( $collection->getAttribute('attributes'), $collection->getAttribute('indexes'), - $dbForProject->getAdapter()->getMaxIndexLength(), - $dbForProject->getAdapter()->getInternalIndexesKeys(), - $dbForProject->getAdapter()->getSupportForIndexArray(), - $dbForProject->getAdapter()->getSupportForSpatialIndexNull(), - $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForProject->getAdapter()->getSupportForVectors(), - $dbForProject->getAdapter()->getSupportForAttributes(), - $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForProject->getAdapter()->getSupportForIdenticalIndexes(), - $dbForProject->getAdapter()->getSupportForObjectIndexes(), - $dbForProject->getAdapter()->getSupportForTrigramIndex(), - $dbForProject->getAdapter()->getSupportForSpatialAttributes(), - $dbForProject->getAdapter()->getSupportForIndex(), - $dbForProject->getAdapter()->getSupportForUniqueIndex(), - $dbForProject->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject() ); if (!$validator->isValid($index)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php index e1c8ad928d..dea62bfc16 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php @@ -12,6 +12,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; @@ -96,7 +97,8 @@ class Delete extends Action // Only update status if removing available index if ($index->getAttribute('status') === 'available') { - $index = $dbForProject->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'deleting')); + $index->setAttribute('status', 'deleting'); + $index = $dbForProject->updateDocument('indexes', $index->getId(), new Document(['status' => 'deleting'])); } $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php index f34fd82997..5d9d425d71 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php @@ -70,12 +70,13 @@ class Update extends Action ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -110,7 +111,8 @@ class Update extends Action ->setAttribute('search', \implode(' ', [$collectionId, $searchName])) ); - $dbForProject->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity); + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity); $queueForEvents ->setContext('database', $database) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index de20d058c4..37213f1061 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -31,6 +31,11 @@ class Get extends Action return UtopiaResponse::MODEL_USAGE_COLLECTION; } + protected function getMetric(): string + { + return METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + } + public function __construct() { $this @@ -64,14 +69,16 @@ class Get extends Action ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('getDatabasesDB') ->callback($this->action(...)); } - public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization): void + public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization, callable $getDatabasesDB): void { $database = $dbForProject->getDocument('databases', $databaseId); $collectionDocument = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); - $collection = $dbForProject->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence()); + $dbForDatabases = $getDatabasesDB($database); + $collection = $dbForDatabases->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence()); if ($collection->isEmpty()) { throw new Exception($this->getNotFoundException(), params: [$collectionId]); @@ -81,7 +88,7 @@ class Get extends Action $stats = $usage = []; $days = $periods[$range]; $metrics = [ - str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), + str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], $this->getMetric()), ]; $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index c2786b9f26..3585bc4477 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -19,7 +19,9 @@ use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; +use Utopia\DSN\DSN; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -30,6 +32,121 @@ class Create extends Action return 'createDatabase'; } + protected function getDatabaseDSN(Document $project): string + { + // TODO: use database worker for for creating the v2 schema if not present + // it is considered that the v2 metadata schema is already created during server start in the http.php + return $this->constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database')); + } + + private function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string + { + $databases = []; + $databaseKeys = []; + /** + * @var string|null $databaseOverride + */ + $databaseOverride = ''; + $dbScheme = ''; + $databaseSharedTables = []; + $databaseSharedTablesV1 = []; + $databaseSharedTablesV2 = []; + $projectSharedTables = []; + $projectSharedTablesV1 = []; + $projectSharedTablesV2 = []; + + switch ($databasetype) { + case DOCUMENTSDB: + $databases = Config::getParam('pools-documentsdb', []); + $databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', ''); + $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); + $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); + $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); + $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + break; + case VECTORSDB: + $databases = Config::getParam('pools-vectorsdb', []); + $databaseKeys = System::getEnv('_APP_DATABASE_VECTORSDB_KEYS', ''); + $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); + $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); + $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); + $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + break; + default: + // legacy/tablesdb + // it is already created during create project + return $dsn; + } + + $isSharedTablesV1 = false; + $isSharedTablesV2 = false; + + if (!empty($dsn)) { + try { + $parsedDsn = new DSN($dsn); + $dsnHost = $parsedDsn->getHost(); + } catch (\InvalidArgumentException) { + $dsnHost = $dsn; + } + + $projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); + $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1); + $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1); + $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2); + } + + if ($region !== 'default') { + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1); + + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $selectedDsn = $databases[$index]; + } else { + if (!empty($dsn)) { + $beforeFilter = \array_values($databases); + if ($isSharedTablesV1) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); + } elseif ($isSharedTablesV2) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2)); + } else { + $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); + } + } + $selectedDsn = !empty($databases) ? $databases[array_rand($databases)] : ''; + } + + if (\in_array($selectedDsn, $databaseSharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $selectedDsn = $schema . '://' . $selectedDsn . '?database=' . $database; + + if (!empty($namespace)) { + $selectedDsn .= '&namespace=' . $namespace; + } + } + try { + new DSN($selectedDsn); + } catch (\InvalidArgumentException) { + $selectedDsn = $dbScheme.'://' . $selectedDsn; + } + + return $selectedDsn; + } + + protected function getDatabaseCollection() + { + return match ($this->getDatabaseType()) { + 'vectorsdb' => (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? [], + default => (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? [], + }; + } public function __construct() { $this @@ -65,13 +182,15 @@ class Create extends Action ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->callback($this->action(...)); } - public function action(string $databaseId, string $name, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void + public function action(string $databaseId, string $name, bool $enabled, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents): void { $databaseId = $databaseId == 'unique()' ? ID::unique() : $databaseId; @@ -82,6 +201,7 @@ class Create extends Action 'enabled' => $enabled, 'search' => implode(' ', [$databaseId, $name]), 'type' => $this->getDatabaseType(), + 'database' => $this->getDatabaseDSN($project) ])); } catch (DuplicateException) { throw new Exception(Exception::DATABASE_ALREADY_EXISTS, params: [$databaseId]); @@ -91,7 +211,7 @@ class Create extends Action $database = $dbForProject->getDocument('databases', $databaseId); - $collections = (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? []; + $collections = $this->getDatabaseCollection(); if (empty($collections)) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'The "collections" collection is not configured.'); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index e2a4491736..f3edf010d4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -10,11 +10,45 @@ abstract class Action extends DatabasesAction * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; + private ?string $databaseType = LEGACY; + + public function getDatabaseType(): string + { + return $this->databaseType; + } + + protected function getDatabasesOperationWriteMetric(): string + { + if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) { + return METRIC_DATABASES_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES; + + } + protected function getDatabasesIdOperationWriteMetric(): string + { + if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES; + } public function setHttpPath(string $path): DatabasesAction { - if (\str_contains($path, '/tablesdb')) { - $this->context = TABLES; + switch (true) { + case str_contains($path, '/tablesdb'): + $this->context = TABLES; + $this->databaseType = TABLESDB; + break; + + case str_contains($path, '/documentsdb'): + $this->context = COLLECTIONS; + $this->databaseType = DOCUMENTSDB; + break; + case str_contains($path, '/vectorsdb'): + $this->context = COLLECTIONS; + $this->databaseType = VECTORSDB; + break; } return parent::setHttpPath($path); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index eebb3a77d5..26457cc4a0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -148,7 +148,7 @@ class Create extends Action $collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $isDependant = isset($dependants[$collectionKey][$documentId]); - $document = $transactionState->getDocument($collectionKey, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionKey, $documentId, $transactionId); if ($document->isEmpty() && !$isDependant && $operation['action'] !== 'upsert') { throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$documentId]); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index a73ad70786..d2f7124aa1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -67,8 +67,10 @@ class Update extends Action ->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject']) ->param('commit', false, new Boolean(), 'Commit transaction?', true) ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('transactionState') ->inject('queueForDeletes') @@ -88,6 +90,7 @@ class Update extends Action * @param bool $rollback * @param UtopiaResponse $response * @param Database $dbForProject + * @param callable $getDatabasesDB * @param Document $user * @param TransactionState $transactionState * @param Delete $queueForDeletes @@ -106,7 +109,7 @@ class Update extends Action * @throws Structure * @throws \Utopia\Http\Exception */ - public function action(string $transactionId, bool $commit, bool $rollback, UtopiaResponse $response, Database $dbForProject, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void { if (!$commit && !$rollback) { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true'); @@ -135,14 +138,52 @@ class Update extends Action } if ($commit) { - $operations = []; $totalOperations = 0; $databaseOperations = []; $currentDocumentId = null; + $firstOperation = $authorization->skip(fn () => $dbForProject->findOne('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + Query::orderAsc(), + ])); + + if ($firstOperation->isEmpty()) { + $transaction = $authorization->skip(fn () => $dbForProject->updateDocument( + 'transactions', + $transactionId, + new Document(['status' => 'committed']) + )); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($transaction); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($transaction, $this->getResponseModel()); + + return; + } + + $databaseDoc = null; + switch ($this->getDatabaseType()) { + case DATABASE_TYPE_DOCUMENTSDB: + case DATABASE_TYPE_VECTORSDB: + $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [ + Query::equal('$sequence', [$firstOperation['databaseInternalId']]) + ])); + break; + default: + // Legacy/tablesdb: use project-level database + $databaseDoc = new Document(['database' => $project->getAttribute('database')]); + break; + } + + $dbForDatabases = $getDatabasesDB($databaseDoc); + try { - $dbForProject->withTransaction(function () use ($dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $queueForStatsUsage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { + $dbForDatabases->withTransaction(function () use ($dbForDatabases, $dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $queueForStatsUsage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'committing', ]))); @@ -182,7 +223,7 @@ class Update extends Action } if ($action === 'delete' && $documentId && empty($data)) { - $doc = $dbForProject->getDocument($collectionId, $documentId); + $doc = $dbForDatabases->getDocument($collectionId, $documentId); if (!$doc->isEmpty()) { $operation['data'] = $doc->getArrayCopy(); $data = $operation['data']; @@ -196,40 +237,40 @@ class Update extends Action switch ($action) { case 'create': - $this->handleCreateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleCreateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'update': - $this->handleUpdateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleUpdateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'upsert': - $this->handleUpsertOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleUpsertOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'delete': - $this->handleDeleteOperation($dbForProject, $collectionId, $documentId, $createdAt, $state); + $this->handleDeleteOperation($dbForDatabases, $collectionId, $documentId, $createdAt, $state); break; case 'increment': - $this->handleIncrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleIncrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'decrement': - $this->handleDecrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleDecrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'bulkCreate': - $count = $this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkCreateOperation($dbForDatabases, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkUpdate': - $count = $this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkUpdateOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkUpsert': - $count = $this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkUpsertOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkDelete': - $count = $this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkDeleteOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; @@ -280,15 +321,16 @@ class Update extends Action } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, $totalOperations); + ->addMetric($this->getDatabasesOperationWriteMetric(), $totalOperations); foreach ($databaseOperations as $sequence => $count) { $queueForStatsUsage->addMetric( - str_replace('{databaseInternalId}', $sequence, METRIC_DATABASE_ID_OPERATIONS_WRITES), + str_replace('{databaseInternalId}', $sequence, $this->getDatabasesIdOperationWriteMetric()), $count ); } + $dbCache = []; foreach ($operations as $operation) { $databaseInternalId = $operation['databaseInternalId']; $collectionInternalId = $operation['collectionInternalId']; @@ -301,6 +343,16 @@ class Update extends Action $data = $data->getArrayCopy(); } + // using a dbCache so only one time database is set with databaseInternalId + if (!isset($dbCache[$databaseInternalId])) { + $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [ + Query::equal('$sequence', [$databaseInternalId]) + ])); + $dbCache[$databaseInternalId] = $getDatabasesDB($databaseDoc); + } + + $dbForDatabases = $dbCache[$databaseInternalId]; + $database = $authorization->skip(fn () => $dbForProject->findOne('databases', [ Query::equal('$sequence', [$databaseInternalId]) ])); @@ -330,7 +382,7 @@ class Update extends Action $eventAction = 'create'; $docId = $documentId ?? $data['$id'] ?? null; if ($docId) { - $doc = $dbForProject->getDocument($collectionId, $docId); + $doc = $dbForDatabases->getDocument($collectionId, $docId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } @@ -341,7 +393,7 @@ class Update extends Action case 'decrement': $eventAction = 'update'; if ($documentId) { - $doc = $dbForProject->getDocument($collectionId, $documentId); + $doc = $dbForDatabases->getDocument($collectionId, $documentId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } @@ -357,7 +409,7 @@ class Update extends Action $eventAction = 'update'; $docId = $documentId ?? $data['$id'] ?? null; if ($docId) { - $doc = $dbForProject->getDocument($collectionId, $docId); + $doc = $dbForDatabases->getDocument($collectionId, $docId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 6f90e77e2b..18e6fd7a8b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -26,6 +26,43 @@ class Get extends Action return 'getDatabaseUsage'; } + protected $databaseType = DATABASE_TYPE_LEGACY; + + public function setHttpPath(string $path): Action + { + $this->databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => DATABASE_TYPE_LEGACY, + }; + + return parent::setHttpPath($path); + } + + protected function getMetrics(): array + { + $metrics = [ + METRIC_DATABASE_ID_COLLECTIONS, + METRIC_DATABASE_ID_DOCUMENTS, + METRIC_DATABASE_ID_STORAGE, + METRIC_DATABASE_ID_OPERATIONS_READS, + METRIC_DATABASE_ID_OPERATIONS_WRITES + ]; + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return $metrics; + } + + return array_map( + fn ($metric) => "{$this->databaseType}.{$metric}", + $metrics + ); + } + + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_USAGE_DATABASE; + } + public function __construct() { $this @@ -74,13 +111,10 @@ class Get extends Action $periods = Config::getParam('usage', []); $stats = $usage = []; $days = $periods[$range]; - $metrics = [ - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_COLLECTIONS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_DOCUMENTS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_STORAGE), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES) - ]; + $metrics = array_map( + fn ($metric) => str_replace('{databaseInternalId}', $database->getSequence(), $metric), + $this->getMetrics() + ); $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { @@ -142,6 +176,6 @@ class Get extends Action 'storage' => $usage[$metrics[2]]['data'], 'databaseReads' => $usage[$metrics[3]]['data'], 'databaseWrites' => $usage[$metrics[4]]['data'], - ]), UtopiaResponse::MODEL_USAGE_DATABASE); + ]), $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index db5ad21358..b8cb774a3e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -24,6 +24,43 @@ class XList extends Action return 'listDatabaseUsage'; } + protected $databaseType = DATABASE_TYPE_LEGACY; + + public function setHttpPath(string $path): Action + { + $this->databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => DATABASE_TYPE_LEGACY, + }; + + return parent::setHttpPath($path); + } + + protected function getMetrics(): array + { + $metrics = [ + METRIC_DATABASES, + METRIC_COLLECTIONS, + METRIC_DOCUMENTS, + METRIC_DATABASES_STORAGE, + METRIC_DATABASES_OPERATIONS_READS, + METRIC_DATABASES_OPERATIONS_WRITES, + ]; + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return $metrics; + } + return array_map( + fn ($metric) => "{$this->databaseType}.{$metric}", + $metrics + ); + } + + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_USAGE_DATABASES; + } + public function __construct() { $this @@ -66,14 +103,7 @@ class XList extends Action $periods = Config::getParam('usage', []); $stats = $usage = []; $days = $periods[$range]; - $metrics = [ - METRIC_DATABASES, - METRIC_COLLECTIONS, - METRIC_DOCUMENTS, - METRIC_DATABASES_STORAGE, - METRIC_DATABASES_OPERATIONS_READS, - METRIC_DATABASES_OPERATIONS_WRITES, - ]; + $metrics = $this->getMetrics(); $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { @@ -136,6 +166,6 @@ class XList extends Action 'storage' => $usage[$metrics[3]]['data'], 'databasesReads' => $usage[$metrics[4]]['data'], 'databasesWrites' => $usage[$metrics[5]]['data'], - ]), UtopiaResponse::MODEL_USAGE_DATABASES); + ]), $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php index 8627fa49c5..e0ffeb8149 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php @@ -17,7 +17,6 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; -use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -92,6 +91,8 @@ class XList extends Action $cursor->setValue($cursorDocument); } + $queries[] = Query::equal('type', [$this->getDatabaseType()]); + try { $databases = $dbForProject->find('databases', $queries); $total = $includeTotal ? $dbForProject->count('databases', $queries, APP_LIMIT_COUNT) : 0; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php new file mode 100644 index 0000000000..d1e91addf7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php @@ -0,0 +1,75 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections') + ->desc('Create collection') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'collections.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'createCollection', + description: '/docs/references/documentsdb/create-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Collection name. Max length: 128 chars.') + ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('attributes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attribute definitions to create. Each attribute should contain: key (string), type (string: string, integer, float, boolean, datetime, relationship), size (integer, required for string type), required (boolean, optional), default (mixed, optional), array (boolean, optional), and type-specific options.', true) + ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php new file mode 100644 index 0000000000..d698b40203 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php @@ -0,0 +1,62 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Delete collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].delete') + ->label('audits.event', 'collection.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'deleteCollection', + description: '/docs/references/documentsdb/delete-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php new file mode 100644 index 0000000000..39a6eba228 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/decrement') + ->desc('Decrement document attribute') + ->groups(['api', 'database']) + ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'decrementDocumentAttribute', + description: '/docs/references/documentsdb/decrement-document-attribute.md', + auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') + ->param('value', 1, new Numeric(), 'Value to decrement the attribute by. The value must be a number.', true) + ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php new file mode 100644 index 0000000000..4fbad6ea85 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/increment') + ->desc('Increment document attribute') + ->groups(['api', 'database']) + ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'incrementDocumentAttribute', + description: '/docs/references/documentsdb/increment-document-attribute.md', + auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') + ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) + ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php new file mode 100644 index 0000000000..094bba2b9d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php @@ -0,0 +1,72 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Delete documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteDocuments', + description: '/docs/references/documentsdb/delete-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php new file mode 100644 index 0000000000..8ada425fd7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Update documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'updateDocuments', + description: '/docs/references/documentsdb/update-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', 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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php new file mode 100644 index 0000000000..2976ffae34 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Upsert documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'upsertDocuments', + description: '/docs/references/documentsdb/upsert-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php new file mode 100644 index 0000000000..8243f038d4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -0,0 +1,116 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Create document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createDocument', + desc: 'Create document', + description: '/docs/references/documentsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documentId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + ] + ), + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createDocuments', + desc: 'Create documents', + description: '/docs/references/documentsdb/create-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') + ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"username":"walter.obrien","email":"walter.obrien@example.com","fullName":"Walter O\'Brien","age":30,"isAdmin":false}') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php new file mode 100644 index 0000000000..9b331d9448 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php @@ -0,0 +1,76 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Delete document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') + ->label('audits.event', 'document.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteDocument', + description: '/docs/references/documentsdb/delete-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php new file mode 100644 index 0000000000..f52ab78d61 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Get document') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'getDocument', + description: '/docs/references/documentsdb/get-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php new file mode 100644 index 0000000000..cc7fe41555 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') + ->desc('List document logs') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'logs', + name: 'listDocumentLogs', + description: '/docs/references/documentsdb/get-document-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php new file mode 100644 index 0000000000..8cb14adf9b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php @@ -0,0 +1,75 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Update document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'updateDocument', + description: '/docs/references/documentsdb/update-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php new file mode 100644 index 0000000000..377f57ff4b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php @@ -0,0 +1,78 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Upsert a document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.upsert') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'upsertDocument', + description: '/docs/references/documentsdb/upsert-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php new file mode 100644 index 0000000000..dac0047be2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('List documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listDocuments', + description: '/docs/references/documentsdb/list-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php new file mode 100644 index 0000000000..53120dd636 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Get collection') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'getCollection', + description: '/docs/references/documentsdb/get-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php new file mode 100644 index 0000000000..3aee3ebcb1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes') + ->desc('Create index') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'index.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createIndex', + description: '/docs/references/documentsdb/create-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_ACCEPTED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).', false, ['dbForProject']) + ->param('key', null, fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject']) + ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE, Database::INDEX_SPATIAL]), 'Index type.') + ->param('attributes', null, fn (Database $dbForProject) => new ArrayList(new Key(true, $dbForProject->getAdapter()->getMaxUIDLength()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.', false, ['dbForProject']) + ->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true) + ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php new file mode 100644 index 0000000000..d4464f171d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php @@ -0,0 +1,67 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Delete index') + ->groups(['api', 'database']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update') + ->label('audits.event', 'index.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/delete-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', '', new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php new file mode 100644 index 0000000000..7fa75b6ed9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Get index') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/get-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php new file mode 100644 index 0000000000..1e16155f76 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes') + ->desc('List indexes') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/list-indexes.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new Indexes(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Indexes::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php new file mode 100644 index 0000000000..51695ea165 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs') + ->desc('List collection logs') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listCollectionLogs', + description: '/docs/references/documentsdb/get-collection-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php new file mode 100644 index 0000000000..052970fec4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Update collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].update') + ->label('audits.event', 'collection.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'updateCollection', + description: '/docs/references/documentsdb/update-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php new file mode 100644 index 0000000000..51dd3c381d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php @@ -0,0 +1,65 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/usage') + ->desc('Get collection usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: null, + name: 'getCollectionUsage', + description: '/docs/references/documentsdb/get-collection-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php new file mode 100644 index 0000000000..638244145b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections') + ->desc('List collections') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'listCollections', + description: '/docs/references/documentsdb/list-collections.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Collections(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Collections::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php new file mode 100644 index 0000000000..f9b425b3e6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb') + ->desc('Create database') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].create') + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'database.create') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'create', + description: '/docs/references/documentsdb/create.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php new file mode 100644 index 0000000000..5fff3131e0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Delete database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].delete') + ->label('audits.event', 'database.delete') + ->label('audits.resource', 'database/{request.databaseId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'delete', + description: '/docs/references/documentsdb/delete.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php new file mode 100644 index 0000000000..309a3b867e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php @@ -0,0 +1,50 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Get database') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'get', + description: '/docs/references/documentsdb/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php new file mode 100644 index 0000000000..8afb0fd1ef --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/logs') + ->desc('List database logs') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: 'logs', + name: 'listDatabaseLogs', + description: '/docs/references/documentsdb/get-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_LOG_LIST, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php new file mode 100644 index 0000000000..9341779dcd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/transactions') + ->desc('Create transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'createTransaction', + description: '/docs/references/documentsdb/create-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php new file mode 100644 index 0000000000..036f2e9600 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Delete transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'deleteTransaction', + description: '/docs/references/documentsdb/delete-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php new file mode 100644 index 0000000000..7def4f0b9a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Get transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'getTransaction', + description: '/docs/references/documentsdb/get-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php new file mode 100644 index 0000000000..bc15d440d1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId/operations') + ->desc('Create operations') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'createOperations', + description: '/docs/references/documentsdb/create-operations.md', + auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php new file mode 100644 index 0000000000..20e418c31f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Update transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'updateTransaction', + description: '/docs/references/documentsdb/update-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('commit', false, new Boolean(), 'Commit transaction?', true) + ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('transactionState') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php new file mode 100644 index 0000000000..b216ce6a4a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/transactions') + ->desc('List transactions') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'listTransactions', + description: '/docs/references/documentsdb/list-transactions.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php new file mode 100644 index 0000000000..4bf5747b54 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Update database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].update') + ->label('audits.event', 'database.update') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'update', + description: '/docs/references/documentsdb/update.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php new file mode 100644 index 0000000000..8373b6bc20 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/usage') + ->desc('Get DocumentsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: null, + name: 'getUsage', + description: '/docs/references/documentsdb/get-database-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_DOCUMENTSDB, + ) + ], + contentType: ContentType::JSON, + ), + ]) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php new file mode 100644 index 0000000000..16535765ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/usage') + ->desc('Get DocumentsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: null, + name: 'listUsage', + description: '/docs/references/documentsdb/list-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_DATABASES, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php new file mode 100644 index 0000000000..13814b37e2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php @@ -0,0 +1,53 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb') + ->desc('List databases') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'list', + description: '/docs/references/documentsdb/list.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Databases(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following columns: ' . implode(', ', Databases::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php index 8aa6e1e28b..eb2293dc28 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php @@ -50,8 +50,10 @@ class Create extends DatabaseCreate ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->callback($this->action(...)); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php index 9d32166a26..48f1136b09 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php @@ -67,6 +67,7 @@ class Create extends CollectionCreate ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of column keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php index aa5b94c00f..97c5465fe3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php @@ -54,6 +54,7 @@ class Delete extends CollectionDelete ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php index 8186e07d61..e683aafba1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php @@ -64,6 +64,7 @@ class Create extends IndexCreate ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php index a1695bdbc6..5433e83307 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php @@ -61,6 +61,7 @@ class Delete extends DocumentsDelete ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php index a6bc78b3e9..060ae26676 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php @@ -63,6 +63,7 @@ class Update extends DocumentsUpdate ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php index 6c0815312d..533bbbf796 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php @@ -63,6 +63,7 @@ class Upsert extends DocumentsUpsert ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php index 63d70b40e2..842425b430 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php @@ -65,6 +65,7 @@ class Decrement extends DecrementDocumentAttribute ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('plan') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php index 5beb8468d9..115fbe6b3f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php @@ -65,6 +65,7 @@ class Increment extends IncrementDocumentAttribute ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('plan') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php index 4385303ffa..5ab7c459bd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php @@ -104,6 +104,7 @@ class Create extends DocumentCreate ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('queueForEvents') ->inject('queueForStatsUsage') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php index bee4dc1093..33c74f8eac 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php @@ -67,6 +67,7 @@ class Delete extends DocumentDelete ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php index f0a7fcbbc2..2e56298b12 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php @@ -57,6 +57,7 @@ class Get extends DocumentGet ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('transactionState') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php index a5f4787b05..e1d821130f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php @@ -50,6 +50,7 @@ class XList extends DocumentLogXList ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('locale') ->inject('geodb') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php index 71abb5d167..863eb7374a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php @@ -65,6 +65,7 @@ class Update extends DocumentUpdate ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php index 0bcf9f9a63..81a3a788a2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php @@ -68,6 +68,7 @@ class Upsert extends DocumentUpsert ->inject('response') ->inject('user') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->inject('transactionState') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 3b8ac0a70e..1ea9f1bf6f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -61,6 +61,7 @@ class XList extends DocumentXList ->inject('response') ->inject('dbForProject') ->inject('user') + ->inject('getDatabasesDB') ->inject('queueForStatsUsage') ->inject('transactionState') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php index c525f97715..88b16d57f0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php @@ -62,6 +62,7 @@ class Update extends CollectionUpdate ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php index 4261ceaab6..6976be014c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php @@ -54,6 +54,7 @@ class Get extends CollectionUsageGet ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('getDatabasesDB') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php index 7da389b265..1b92312742 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php @@ -51,8 +51,10 @@ class Update extends TransactionsUpdate ->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject']) ->param('commit', false, new Boolean(), 'Commit transaction?', true) ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('transactionState') ->inject('queueForDeletes') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php new file mode 100644 index 0000000000..b85a8b30b4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -0,0 +1,208 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections') + ->desc('Create collection') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'collection.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'createCollection', + description: '/docs/references/vectorsdb/create-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Collection name. Max length: 128 chars.') + ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action(string $databaseId, string $collectionId, string $name, int $dimension, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + { + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collectionId = $collectionId === 'unique()' ? ID::unique() : $collectionId; + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions) ?? []; + + try { + $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ + '$id' => $collectionId, + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + '$permissions' => $permissions, + 'documentSecurity' => $documentSecurity, + 'enabled' => $enabled, + 'name' => $name, + 'dimension' => $dimension, + 'search' => \implode(' ', [$collectionId, $name]), + ])); + + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (LimitException) { + throw new Exception($this->getLimitException()); + } catch (NotFoundException) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + /** @var Database $dbForDatabases */ + $dbForDatabases = $getDatabasesDB($database); + + $attributes = []; + $indexes = []; + $collections = (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? []; + foreach ($collections['defaultAttributes'] as $attribute) { + if ($attribute['$id'] === 'embeddings') { + $attribute['size'] = $dimension; + } + $attributes[] = new Document($attribute); + } + foreach ($collections['defaultIndexes'] as $index) { + $indexes[] = new Document($index); + } + try { + // passing null in creates only creates the metadata collection + if (!$dbForDatabases->exists(null, Database::METADATA)) { + $dbForDatabases->create(); + } + $dbForDatabases->createCollection( + id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + permissions: $permissions, + documentSecurity: $documentSecurity, + attributes:$attributes, + indexes:$indexes + ); + // Create attribute and indexes metadata documents in the attributes and indexes collections + // needed for the get and list calls + $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { + $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; + return new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $attributeConfig['type'], + 'status' => 'available', + 'size' => $dimension, + 'required' => $attributeConfig['required'] ?? false, + 'signed' => $attributeConfig['signed'] ?? false, + 'default' => $attributeConfig['default'] ?? null, + 'array' => $attributeConfig['array'] ?? false, + 'format' => $attributeConfig['format'] ?? '', + 'formatOptions' => $attributeConfig['formatOptions'] ?? [], + 'filters' => $attributeConfig['filters'] ?? [], + 'options' => $attributeConfig['options'] ?? [], + ]); + }, $collections['defaultAttributes']); + $dbForProject->createDocuments('attributes', $attributeDocs); + + $indexDocs = array_map(function ($indexConfig) use ($database, $collection, $databaseId, $collectionId) { + $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; + + return new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'available', + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $indexConfig['type'], + 'attributes' => $indexConfig['attributes'] ?? [], + 'lengths' => $indexConfig['lengths'] ?? [], + 'orders' => $indexConfig['orders'] ?? [], + ]); + }, $collections['defaultIndexes']); + + if (!empty($indexDocs)) { + $dbForProject->createDocuments('indexes', $indexDocs); + } + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (IndexException) { + throw new Exception($this->getInvalidIndexException()); + } catch (LimitException) { + throw new Exception($this->getLimitException()); + } + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam($this->getEventsParamKey(), $collection->getId()); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($collection, $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php new file mode 100644 index 0000000000..f1188868aa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php @@ -0,0 +1,62 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Delete collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].delete') + ->label('audits.event', 'collection.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'deleteCollection', + description: '/docs/references/vectorsdb/delete-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php new file mode 100644 index 0000000000..c8726711f2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php @@ -0,0 +1,72 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Delete documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteDocuments', + description: '/docs/references/vectorsdb/delete-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php new file mode 100644 index 0000000000..b469316405 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Update documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'updateDocuments', + description: '/docs/references/vectorsdb/update-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', 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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php new file mode 100644 index 0000000000..bb96a9675c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Upsert documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'upsertDocuments', + description: '/docs/references/vectorsdb/upsert-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php new file mode 100644 index 0000000000..79ae43d087 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php @@ -0,0 +1,116 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Create document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createDocument', + desc: 'Create document', + description: '/docs/references/vectorsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documentId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + ] + ), + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createDocuments', + desc: 'Create documents', + description: '/docs/references/vectorsdb/create-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') + ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"embeddings": [0.12, -0.55, 0.88, 1.02], "metadata": {"key":"value"} }') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php new file mode 100644 index 0000000000..d7d7cdee00 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php @@ -0,0 +1,76 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Delete document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') + ->label('audits.event', 'document.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteDocument', + description: '/docs/references/vectorsdb/delete-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php new file mode 100644 index 0000000000..9a50d9a1b9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Get document') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'getDocument', + description: '/docs/references/vectorsdb/get-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php new file mode 100644 index 0000000000..dea9d30119 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') + ->desc('List document logs') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'logs', + name: 'listDocumentLogs', + description: '/docs/references/vectorsdb/get-document-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php new file mode 100644 index 0000000000..c26b2a70b9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php @@ -0,0 +1,75 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Update document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'updateDocument', + description: '/docs/references/vectorsdb/update-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php new file mode 100644 index 0000000000..df4cb0f191 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php @@ -0,0 +1,79 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Upsert a document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.upsert') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'upsertDocument', + description: '/docs/references/vectorsdb/upsert-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Document ID.', false, ['dbForProject']) + ->param('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php new file mode 100644 index 0000000000..d4a5d06269 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('List documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listDocuments', + description: '/docs/references/vectorsdb/list-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->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). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('queueForStatsUsage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php new file mode 100644 index 0000000000..9619bb5048 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Get collection') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'getCollection', + description: '/docs/references/vectorsdb/get-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php new file mode 100644 index 0000000000..a535dd5724 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes') + ->desc('Create index') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'index.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.tableId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createIndex', + description: '/docs/references/vectorsdb/create-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_ACCEPTED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->param('type', null, new WhiteList([Database::INDEX_HNSW_EUCLIDEAN,Database::INDEX_HNSW_DOT, Database::INDEX_HNSW_COSINE, Database::INDEX_OBJECT, Database::INDEX_KEY, Database::INDEX_UNIQUE]), 'Index type.') + ->param('attributes', null, new ArrayList(new Key(true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.') + ->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true) + ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php new file mode 100644 index 0000000000..5c7fc47ee0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php @@ -0,0 +1,67 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Delete index') + ->groups(['api', 'database']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update') + ->label('audits.event', 'index.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/delete-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', '', new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php new file mode 100644 index 0000000000..4cf646acba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Get index') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/get-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php new file mode 100644 index 0000000000..acc46fb570 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes') + ->desc('List indexes') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/list-indexes.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new Indexes(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Indexes::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php new file mode 100644 index 0000000000..cd0e45eb47 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php @@ -0,0 +1,57 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs') + ->desc('List collection logs') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listCollectionLogs', + description: '/docs/references/vectorsdb/get-collection-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php new file mode 100644 index 0000000000..2ec5f8991f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php @@ -0,0 +1,117 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Update collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].update') + ->label('audits.event', 'collection.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'updateCollection', + description: '/docs/references/vectorsdb/update-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_VECTORSDB_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') + ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimensions.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action(string $databaseId, string $collectionId, ?string $name, ?int $dimensions, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + { + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + if ($collection->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + $permissions ??= $collection->getPermissions(); + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); + + $enabled ??= $collection->getAttribute('enabled', true); + + $updated = $dbForProject->updateDocument( + 'database_' . $database->getSequence(), + $collectionId, + $collection + ->setAttribute('name', $name ?? $collection->getAttribute('name')) + ->setAttribute('dimension', $dimensions ?? $collection->getAttribute('dimension')) + ->setAttribute('$permissions', $permissions) + ->setAttribute('documentSecurity', $documentSecurity) + ->setAttribute('enabled', $enabled) + ->setAttribute('search', \implode(' ', [$collectionId, $name ?? $collection->getAttribute('name')])) + ); + + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $updated->getSequence(), $permissions, $documentSecurity); + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam($this->getEventsParamKey(), $updated->getId()); + + $response->dynamic($updated, $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php new file mode 100644 index 0000000000..7e0f79a9f1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/usage') + ->desc('Get collection usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: null, + name: 'getCollectionUsage', + description: '/docs/references/vectorsdb/get-collection-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php new file mode 100644 index 0000000000..7ba26b8b6a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections') + ->desc('List collections') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'listCollections', + description: '/docs/references/vectorsdb/list-collections.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Collections(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Collections::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php new file mode 100644 index 0000000000..cc2914fc10 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb') + ->desc('Create database') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].create') + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'database.create') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'create', + description: '/docs/references/vectorsdb/create.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php new file mode 100644 index 0000000000..2109e2bd68 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Delete database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].delete') + ->label('audits.event', 'database.delete') + ->label('audits.resource', 'database/{request.databaseId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'delete', + description: '/docs/references/vectorsdb/delete.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php new file mode 100644 index 0000000000..98602efa3c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -0,0 +1,154 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/embeddings/text') + ->desc('Create Text Embeddings') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_EMBEDDINGS_TEXT) + ->label('audits.event', 'embedding.create') + ->label('audits.resource', 'vectorsdb/embeddings/text') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createTextEmbeddings', + desc: 'Create Text Embedding', + description: '/docs/references/vectorsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] + ) + ]) + ->param('texts', [], fn (array $plan) => new ArrayList(new Text(0), $plan['databasesMaxEmbeddingTexts'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of text to generate embeddings.', false, ['plan']) + ->param('model', Ollama::MODEL_EMBEDDING_GEMMA, new WhiteList(Ollama::MODELS), 'The embedding model to use for generating vector embeddings.', true) + ->inject('response') + ->inject('project') + ->inject('embeddingAgent') + ->inject('queueForStatsUsage') + ->inject('log') + ->inject('logger') + ->callback($this->action(...)); + } + + public function action(array $texts, string $model, UtopiaResponse $response, Document $project, Agent $embeddingAgent, StatsUsage $queueForStatsUsage, Log $log, ?Logger $logger): void + { + $results = []; + $embeddingAgent->getAdapter()->setModel($model); + $dimension = $embeddingAgent->getAdapter()->getEmbeddingDimension(); + + $totalDuration = 0; + $totalTokens = 0; + $totalErrors = 0; + foreach ($texts as $text) { + $embedding = []; + $error = ''; + try { + $embedResult = $embeddingAgent->embed($text); + $embedding = $embedResult['embedding'] ?? []; + $totalDuration += $embedResult['totalDuration'] ?? 0; + $totalTokens += $embedResult['tokensProcessed'] ?? 0; + } catch (\Exception $e) { + $error = 'Error while generating embedding'; + $totalErrors += 1; + if ($logger) { + $log->setNamespace("http"); + $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); + $log->setVersion(System::getEnv('_APP_VERSION', 'UNKNOWN')); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($e->getMessage()); + + $log->addTag('embeddingModel', $model); + $log->addTag('code', $e->getCode()); + $log->addTag('projectId', $project->getId()); + + $log->addExtra('file', $e->getFile()); + $log->addExtra('line', $e->getLine()); + $log->addExtra('trace', $e->getTraceAsString()); + + $logger->addLog($log); + } + } + + $results[] = new Document([ + 'model' => $model, + 'dimension' => $dimension, + 'embedding' => $embedding, + 'error' => $error + ]); + } + $embeddings = new Document([ + 'embeddings' => $results, + 'total' => \count($results), + ]); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($embeddings, $this->getBulkResponseModel()); + + $queueForStatsUsage + ->setProject($project) + ->addMetric(METRIC_EMBEDDINGS_TEXT, \count($texts)) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT), \count($texts)) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, $totalTokens) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS), $totalTokens) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, $totalDuration) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION), $totalDuration) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR, $totalErrors) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR), $totalErrors); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php new file mode 100644 index 0000000000..a79632b105 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php @@ -0,0 +1,49 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Get database') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'get', + description: '/docs/references/vectorsdb/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php new file mode 100644 index 0000000000..d8c1df5f04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/logs') + ->desc('List database logs') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: 'logs', + name: 'listDatabaseLogs', + description: '/docs/references/vectorsdb/get-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_LOG_LIST, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), '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 and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php new file mode 100644 index 0000000000..cb67d3f7f1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/transactions') + ->desc('Create transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'createTransaction', + description: '/docs/references/vectorsdb/create-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php new file mode 100644 index 0000000000..0ac2caecba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Delete transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'deleteTransaction', + description: '/docs/references/vectorsdb/delete-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php new file mode 100644 index 0000000000..fa4cc86cdd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Get transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'getTransaction', + description: '/docs/references/vectorsdb/get-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php new file mode 100644 index 0000000000..830c0c3fe1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId/operations') + ->desc('Create operations') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'createOperations', + description: '/docs/references/vectorsdb/create-operations.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php new file mode 100644 index 0000000000..babfe11a3a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Update transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'updateTransaction', + description: '/docs/references/vectorsdb/update-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('commit', false, new Boolean(), 'Commit transaction?', true) + ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('transactionState') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php new file mode 100644 index 0000000000..fb95667ffb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/transactions') + ->desc('List transactions') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'listTransactions', + description: '/docs/references/vectorsdb/list-transactions.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php new file mode 100644 index 0000000000..0b10d6d98b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php @@ -0,0 +1,57 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Update database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].update') + ->label('audits.event', 'database.update') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'update', + description: '/docs/references/vectorsdb/update.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php new file mode 100644 index 0000000000..051e2e39fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/usage') + ->desc('Get VectorsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: null, + name: 'getUsage', + description: '/docs/references/vectorsdb/get-database-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_VECTORSDB, + ) + ], + contentType: ContentType::JSON, + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php new file mode 100644 index 0000000000..d91a5963c4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/usage') + ->desc('Get VectorsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: null, + name: 'listUsage', + description: '/docs/references/vectorsdb/list-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_VECTORSDBS, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php new file mode 100644 index 0000000000..e18a89c6a4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php @@ -0,0 +1,53 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb') + ->desc('List databases') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'list', + description: '/docs/references/vectorsdb/list.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Databases(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following columns: ' . implode(', ', Databases::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Http.php b/src/Appwrite/Platform/Modules/Databases/Services/Http.php index f683f537bc..5146382b56 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Http.php @@ -3,8 +3,10 @@ namespace Appwrite\Platform\Modules\Databases\Services; use Appwrite\Platform\Modules\Databases\Http\Init\Timeout; +use Appwrite\Platform\Modules\Databases\Services\Registry\DocumentsDB as DocumentsDBRegistry; use Appwrite\Platform\Modules\Databases\Services\Registry\Legacy as LegacyRegistry; -use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBRegistry; +use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBDBRegistry; +use Appwrite\Platform\Modules\Databases\Services\Registry\VectorsDB as VectorsDBRegistry; use Utopia\Platform\Service; class Http extends Service @@ -17,7 +19,9 @@ class Http extends Service foreach ([ LegacyRegistry::class, - TablesDBRegistry::class, + TablesDBDBRegistry::class, + DocumentsDBRegistry::class, + VectorsDBRegistry::class ] as $registrar) { new $registrar($this); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php new file mode 100644 index 0000000000..a1e3538cac --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -0,0 +1,109 @@ +registerDatabaseActions($service); + $this->registerTableActions($service); + $this->registerIndexActions($service); + $this->registerRowActions($service); + $this->registerTransactionActions($service); + } + + private function registerDatabaseActions(Service $service): void + { + $service->addAction(CreateTablesDatabase::getName(), new CreateTablesDatabase()); + $service->addAction(GetTablesDatabase::getName(), new GetTablesDatabase()); + $service->addAction(UpdateTablesDatabase::getName(), new UpdateTablesDatabase()); + $service->addAction(DeleteTablesDatabase::getName(), new DeleteTablesDatabase()); + $service->addAction(ListTablesDatabase::getName(), new ListTablesDatabase()); + $service->addAction(GetTablesDatabaseUsage::getName(), new GetTablesDatabaseUsage()); + $service->addAction(ListTablesDatabaseUsage::getName(), new ListTablesDatabaseUsage()); + } + + private function registerTableActions(Service $service): void + { + $service->addAction(CreateTable::getName(), new CreateTable()); + $service->addAction(GetTable::getName(), new GetTable()); + $service->addAction(UpdateTable::getName(), new UpdateTable()); + $service->addAction(DeleteTable::getName(), new DeleteTable()); + $service->addAction(ListTables::getName(), new ListTables()); + $service->addAction(ListTableLogs::getName(), new ListTableLogs()); + $service->addAction(GetTableUsage::getName(), new GetTableUsage()); + } + + private function registerIndexActions(Service $service): void + { + $service->addAction(CreateColumnIndex::getName(), new CreateColumnIndex()); + $service->addAction(GetColumnIndex::getName(), new GetColumnIndex()); + $service->addAction(DeleteColumnIndex::getName(), new DeleteColumnIndex()); + $service->addAction(ListColumnIndexes::getName(), new ListColumnIndexes()); + } + + private function registerRowActions(Service $service): void + { + $service->addAction(CreateRow::getName(), new CreateRow()); + $service->addAction(GetRow::getName(), new GetRow()); + $service->addAction(UpdateRow::getName(), new UpdateRow()); + $service->addAction(UpdateRows::getName(), new UpdateRows()); + $service->addAction(UpsertRow::getName(), new UpsertRow()); + $service->addAction(UpsertRows::getName(), new UpsertRows()); + $service->addAction(DeleteRow::getName(), new DeleteRow()); + $service->addAction(DeleteRows::getName(), new DeleteRows()); + $service->addAction(ListRows::getName(), new ListRows()); + $service->addAction(ListRowLogs::getName(), new ListRowLogs()); + $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); + $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); + } + + private function registerTransactionActions(Service $service): void + { + $service->addAction(CreateTransaction::getName(), new CreateTransaction()); + $service->addAction(GetTransaction::getName(), new GetTransaction()); + $service->addAction(UpdateTransaction::getName(), new UpdateTransaction()); + $service->addAction(DeleteTransaction::getName(), new DeleteTransaction()); + $service->addAction(ListTransactions::getName(), new ListTransactions()); + $service->addAction(CreateOperations::getName(), new CreateOperations()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php new file mode 100644 index 0000000000..9496b1781a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -0,0 +1,110 @@ +registerDatabaseActions($service); + $this->registerCollectionActions($service); + $this->registerIndexActions($service); + $this->registerDocumentActions($service); + $this->registerEmbeddingActions($service); + $this->registerTransactionActions($service); + } + + private function registerDatabaseActions(Service $service): void + { + $service->addAction(CreateVectorDatabase::getName(), new CreateVectorDatabase()); + $service->addAction(GetVectorDatabase::getName(), new GetVectorDatabase()); + $service->addAction(UpdateVectorDatabase::getName(), new UpdateVectorDatabase()); + $service->addAction(DeleteVectorDatabase::getName(), new DeleteVectorDatabase()); + $service->addAction(ListVectorDatabases::getName(), new ListVectorDatabases()); + $service->addAction(GetVectorDatabaseUsage::getName(), new GetVectorDatabaseUsage()); + $service->addAction(ListVectorDatabaseUsage::getName(), new ListVectorDatabaseUsage()); + } + + private function registerCollectionActions(Service $service): void + { + $service->addAction(CreateCollection::getName(), new CreateCollection()); + $service->addAction(GetCollection::getName(), new GetCollection()); + $service->addAction(UpdateCollection::getName(), new UpdateCollection()); + $service->addAction(DeleteCollection::getName(), new DeleteCollection()); + $service->addAction(ListCollections::getName(), new ListCollections()); + $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs()); + $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage()); + } + + private function registerIndexActions(Service $service): void + { + $service->addAction(CreateIndex::getName(), new CreateIndex()); + $service->addAction(GetIndex::getName(), new GetIndex()); + $service->addAction(DeleteIndex::getName(), new DeleteIndex()); + $service->addAction(ListIndexes::getName(), new ListIndexes()); + } + + private function registerDocumentActions(Service $service): void + { + $service->addAction(CreateDocument::getName(), new CreateDocument()); + $service->addAction(UpdateDocument::getName(), new UpdateDocument()); + $service->addAction(UpsertDocument::getName(), new UpsertDocument()); + $service->addAction(GetDocument::getName(), new GetDocument()); + $service->addAction(ListDocuments::getName(), new ListDocuments()); + $service->addAction(DeleteDocument::getName(), new DeleteDocument()); + $service->addAction(UpdateDocuments::getName(), new UpdateDocuments()); + $service->addAction(UpsertDocuments::getName(), new UpsertDocuments()); + $service->addAction(DeleteDocuments::getName(), new DeleteDocuments()); + } + + private function registerTransactionActions(Service $service): void + { + $service->addAction(CreateTransaction::getName(), new CreateTransaction()); + $service->addAction(GetTransaction::getName(), new GetTransaction()); + $service->addAction(UpdateTransaction::getName(), new UpdateTransaction()); + $service->addAction(DeleteTransaction::getName(), new DeleteTransaction()); + $service->addAction(ListTransactions::getName(), new ListTransactions()); + $service->addAction(CreateOperations::getName(), new CreateOperations()); + } + + private function registerEmbeddingActions(Service $service): void + { + $service->addAction(CreateTextEmbeddings::getName(), new CreateTextEmbeddings()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 9d686f72ed..66ed3e0eab 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -36,6 +36,7 @@ class Databases extends Action ->inject('project') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForRealtime') ->inject('log') ->callback($this->action(...)); @@ -51,7 +52,7 @@ class Databases extends Action * @return void * @throws \Exception */ - public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime, Log $log): void + public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { $payload = $message->getPayload() ?? []; @@ -63,7 +64,10 @@ class Databases extends Action $document = new Document($payload['row'] ?? $payload['document'] ?? []); $collection = new Document($payload['table'] ?? $payload['collection'] ?? []); $database = new Document($payload['database'] ?? []); - + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($database); $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); @@ -74,12 +78,12 @@ class Databases extends Action $log->addTag('databaseId', $database->getId()); match (\strval($type)) { - DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject), - DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject), + DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject, $dbForDatabases), + DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases), DATABASE_TYPE_CREATE_ATTRIBUTE => $this->createAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), + DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), + DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), + DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), default => throw new Exception('No database operation for type: ' . \strval($type)), }; @@ -244,6 +248,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -251,7 +256,7 @@ class Databases extends Action * @throws \Exception * @throws \Throwable **/ - private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForDatabases, Database $dbForProject, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -322,13 +327,19 @@ class Databases extends Action $dbForProject->updateDocument( 'attributes', $attribute->getId(), - $attribute->setAttribute('status', 'stuck') + new Document([ + 'error' => $attribute->getAttribute('error'), + 'status' => 'stuck', + ]) ); if (!$relatedAttribute->isEmpty()) { $dbForProject->updateDocument( 'attributes', $relatedAttribute->getId(), - $relatedAttribute->setAttribute('status', 'stuck') + new Document([ + 'error' => $relatedAttribute->getAttribute('error'), + 'status' => 'stuck', + ]) ); } @@ -380,9 +391,13 @@ class Databases extends Action } if ($exists) { // Delete the duplicate if created, else update in db - $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $queueForRealtime); + $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime); } else { - $dbForProject->updateDocument('indexes', $index->getId(), $index); + $dbForProject->updateDocument('indexes', $index->getId(), new Document([ + 'attributes' => $index->getAttribute('attributes'), + 'lengths' => $index->getAttribute('lengths'), + 'orders' => $index->getAttribute('orders'), + ])); } } } @@ -405,6 +420,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -413,7 +429,7 @@ class Databases extends Action * @throws DatabaseException * @throws \Throwable */ - private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -433,7 +449,7 @@ class Databases extends Action $project = $dbForPlatform->getDocument('projects', $projectId); try { - if (!$dbForProject->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) { + if (!$dbForDatabases->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) { throw new DatabaseException('Failed to create Index'); } $dbForProject->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'available')); @@ -463,6 +479,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -471,7 +488,7 @@ class Databases extends Action * @throws DatabaseException * @throws \Throwable */ - private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -487,7 +504,7 @@ class Databases extends Action $project = $dbForPlatform->getDocument('projects', $projectId); try { - if ($status !== 'failed' && !$dbForProject->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) { + if ($status !== 'failed' && !$dbForDatabases->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) { throw new DatabaseException('Failed to delete index'); } $dbForProject->deleteDocument('indexes', $index->getId()); @@ -515,14 +532,15 @@ class Databases extends Action /** * @param Document $database - * @param $dbForProject + * @param Database $dbForProject + * @param Database $dbForDatabases * @return void * @throws Exception */ - protected function deleteDatabase(Document $database, $dbForProject): void + protected function deleteDatabase(Document $database, Database $dbForProject, Database $dbForDatabases): void { - $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject) { - $this->deleteCollection($database, $collection, $dbForProject); + $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject, $dbForDatabases) { + $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases); }); $dbForProject->deleteCollection('database_' . $database->getSequence()); @@ -532,6 +550,7 @@ class Databases extends Action * @param Document $database * @param Document $collection * @param Database $dbForProject + * @param Database $dbForDatabases * @return void * @throws Authorization * @throws Conflict @@ -540,7 +559,7 @@ class Databases extends Action * @throws Structure * @throws Exception */ - protected function deleteCollection(Document $database, Document $collection, Database $dbForProject): void + protected function deleteCollection(Document $database, Document $collection, Database $dbForProject, Database $dbForDatabases): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -550,7 +569,7 @@ class Databases extends Action $collectionInternalId = $collection->getSequence(); $databaseInternalId = $database->getSequence(); - $dbForProject->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence()); + $dbForDatabases->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence()); /** * Related collections relating to current collection diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index ddb1f486d7..65b6ffd5bb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -227,7 +227,9 @@ class Create extends Action foreach ($activeDeployments as $activeDeployment) { $activeDeployment->setAttribute('activate', false); - $dbForProject->updateDocument('deployments', $activeDeployment->getId(), $activeDeployment); + $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([ + 'activate' => false, + ])); } } @@ -255,14 +257,17 @@ class Create extends Action 'type' => $type ])); - $function = $function - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceSize', $fileSize)->setAttribute('sourceMetadata', $metadata)); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceSize' => $fileSize, + 'sourceMetadata' => $metadata, + ])); } // Start the build @@ -295,14 +300,17 @@ class Create extends Action 'type' => $type ])); - $function = $function - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceChunksUploaded', $chunksUploaded)->setAttribute('sourceMetadata', $metadata)); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php index 5cab00c0fa..3d75919eb8 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php @@ -107,11 +107,12 @@ class Delete extends Action $function = $dbForProject->updateDocument( 'functions', $function->getId(), - $function - ->setAttribute('latestDeploymentCreatedAt', $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt()) - ->setAttribute('latestDeploymentInternalId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence()) - ->setAttribute('latestDeploymentId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getId()) - ->setAttribute('latestDeploymentStatus', $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', '')) + new Document([ + 'latestDeploymentCreatedAt' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt(), + 'latestDeploymentInternalId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence(), + 'latestDeploymentId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getId(), + 'latestDeploymentStatus' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''), + ]) ); } 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 2769d3dc1e..9884b12dba 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -119,7 +120,12 @@ class Create extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $function->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $function->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $function->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'), + ])); $queueForBuilds ->setType(BUILD_TYPE_DEPLOYMENT) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Status/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Status/Update.php index c3cd93830a..dab477ef1f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Status/Update.php @@ -91,7 +91,7 @@ class Update extends Action $endTime = new \DateTime('now'); $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttributes([ + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ 'buildEndedAt' => DateTime::now(), 'buildDuration' => $duration, 'status' => 'canceled' @@ -99,7 +99,9 @@ class Update extends Action if ($deployment->getSequence() === $function->getAttribute('latestDeploymentInternalId', '')) { $function = $function->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'), + ])); } try { 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 28226cc98a..53af82e701 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -174,7 +174,12 @@ class Create extends Base ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $function->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $function->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $function->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'), + ])); $this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 1a4cc45081..bc506c654a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -180,7 +180,8 @@ class Create extends Base $version = $function->getAttribute('version', 'v2'); $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); - $spec = Config::getParam('specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + + $spec = Config::getParam('specifications')[$function->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; $runtime = (isset($runtimes[$function->getAttribute('runtime', '')])) ? $runtimes[$function->getAttribute('runtime', '')] : null; @@ -420,6 +421,11 @@ class Create extends Base try { $version = $function->getAttribute('version', 'v2'); $command = $runtime['startCommand']; + + if (!empty($deployment->getAttribute('startCommand', ''))) { + $command = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + } + $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php index 4a04afa119..21ec3c66ce 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php @@ -12,6 +12,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; @@ -119,7 +120,10 @@ class Delete extends Base ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('active', false); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'active' => $schedule->getAttribute('active'), + ]))); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 4cabccccee..d281c64414 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -93,16 +93,23 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) - ->param('specification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) - ), 'Runtime specification for the function and builds.', true, ['plan']) + ), 'Build specification for the function deployments.', true, ['plan']) + ->param('runtimeSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + $plan, + Config::getParam('specifications', []), + System::getEnv('_APP_COMPUTE_CPUS', 0), + System::getEnv('_APP_COMPUTE_MEMORY', 0) + ), 'Runtime specification for the function executions.', true, ['plan']) ->param('templateRepository', '', new Text(128, 0), 'Repository name of the template.', true, deprecated: true) ->param('templateOwner', '', new Text(128, 0), 'The name of the owner of the template.', true, deprecated: true) ->param('templateRootDirectory', '', new Text(128, 0), 'Path to function code in the template repo.', true, deprecated: true) ->param('templateVersion', '', new Text(128, 0), 'Version (tag) for the repo linked to the function template.', true, deprecated: true) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('response') ->inject('dbForProject') ->inject('timelimit') @@ -138,11 +145,13 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - string $specification, + string $buildSpecification, + string $runtimeSpecification, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateVersion, + int $deploymentRetention, Response $response, Database $dbForProject, callable $timelimit, @@ -215,6 +224,7 @@ class Create extends Base 'logging' => $logging, 'name' => $name, 'runtime' => $runtime, + 'deploymentRetention' => $deploymentRetention, 'deploymentInternalId' => '', 'deploymentId' => '', 'events' => $events, @@ -225,7 +235,6 @@ class Create extends Base 'entrypoint' => $entrypoint, 'commands' => $commands, 'scopes' => $scopes, - 'deploymentRetention' => 0, 'startCommand' => '', 'search' => implode(' ', [$functionId, $name, $runtime]), 'version' => 'v5', @@ -237,9 +246,8 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'specification' => $specification, - 'buildSpecification' => $specification, - 'runtimeSpecification' => $specification, + 'buildSpecification' => $buildSpecification, + 'runtimeSpecification' => $runtimeSpecification, ])); } catch (DuplicateException) { throw new Exception(Exception::FUNCTION_ALREADY_EXISTS); @@ -283,7 +291,12 @@ class Create extends Base $function->setAttribute('repositoryInternalId', $repository->getSequence()); } - $function = $dbForProject->updateDocument('functions', $function->getId(), $function); + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'scheduleId' => $function->getAttribute('scheduleId'), + 'scheduleInternalId' => $function->getAttribute('scheduleInternalId'), + 'repositoryId' => $function->getAttribute('repositoryId'), + 'repositoryInternalId' => $function->getAttribute('repositoryInternalId'), + ])); // Backwards compatibility with 1.6 behaviour $requestFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); @@ -321,12 +334,12 @@ class Create extends Base referenceType: 'branch' ); - $function = $function - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); } elseif (!$template->isEmpty()) { // Deploy non-VCS from template $deploymentId = ID::unique(); @@ -347,12 +360,12 @@ class Create extends Base 'activate' => true, ])); - $function = $function - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('functions', $function->getId(), $function); + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); $queueForBuilds ->setType(BUILD_TYPE_DEPLOYMENT) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php index 1d108c425f..fb45cee82f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php @@ -13,6 +13,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -90,7 +91,10 @@ class Delete extends Base $schedule ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('active', false); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'active' => $schedule->getAttribute('active'), + ]))); } $queueForDeletes diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Deployment/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Deployment/Update.php index e78b17bd3f..6b6eda36ab 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Deployment/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Deployment/Update.php @@ -103,7 +103,11 @@ class Update extends Base ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $function->getAttribute('schedule')) ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId'))); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'schedule' => $schedule->getAttribute('schedule'), + 'active' => $schedule->getAttribute('active'), + ]))); $queries = [ Query::equal('trigger', ['manual']), @@ -119,7 +123,10 @@ class Update extends Base ->setAttribute('deploymentId', $deployment->getId()) ->setAttribute('deploymentInternalId', $deployment->getSequence()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), $rule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([ + 'deploymentId' => $rule->getAttribute('deploymentId'), + 'deploymentInternalId' => $rule->getAttribute('deploymentInternalId'), + ]))); }, $queries)); $queueForEvents diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 63d1a331ac..a627bae9dd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -87,12 +87,19 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) - ->param('specification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) - ), 'Runtime specification for the function and builds.', true, ['plan']) + ), 'Build specification for the function deployments.', true, ['plan']) + ->param('runtimeSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + $plan, + Config::getParam('specifications', []), + System::getEnv('_APP_COMPUTE_CPUS', 0), + System::getEnv('_APP_COMPUTE_MEMORY', 0) + ), 'Runtime specification for the function executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -124,7 +131,9 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - string $specification, + string $buildSpecification, + string $runtimeSpecification, + int $deploymentRetention, Request $request, Response $response, Database $dbForProject, @@ -209,7 +218,7 @@ class Update extends Base 'resourceId' => $function->getId(), 'resourceInternalId' => $function->getSequence(), 'resourceType' => 'function', - 'providerPullRequestIds' => [] + 'providerPullRequestIds' => [], ])); $repositoryId = $repository->getId(); @@ -229,13 +238,22 @@ class Update extends Base } // Enforce Cold Start if spec limits change. - if ($function->getAttribute('specification') !== $specification && !empty($function->getAttribute('deploymentId'))) { - try { - $executor->deleteRuntime($project->getId(), $function->getAttribute('deploymentId')); - } catch (\Throwable $th) { - // Don't throw if the deployment doesn't exist - if ($th->getCode() !== 404) { - throw $th; + if (!empty($function->getAttribute('deploymentId'))) { + $specsChanged = false; + if ($function->getAttribute('runtimeSpecification', '') !== $runtimeSpecification) { + $specsChanged = true; + } elseif ($function->getAttribute('buildSpecification', '') !== $buildSpecification) { + $specsChanged = true; + } + + if ($specsChanged) { + try { + $executor->deleteRuntime($project->getId(), $function->getAttribute('deploymentId')); + } catch (\Throwable $th) { + // Don't throw if the deployment doesn't exist + if ($th->getCode() !== 404) { + throw $th; + } } } } @@ -253,8 +271,7 @@ class Update extends Base 'entrypoint' => $entrypoint, 'commands' => $commands, 'scopes' => $scopes, - 'deploymentRetention' => 0, - 'startCommand' => '', + 'deploymentRetention' => $deploymentRetention, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'providerRepositoryId' => $providerRepositoryId, @@ -263,9 +280,8 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'specification' => $specification, - 'buildSpecification' => $specification, - 'runtimeSpecification' => $specification, + 'buildSpecification' => $buildSpecification, + 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$functionId, $name, $runtime]), ]))); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php index fa5ca39e7e..fee5b0095d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php @@ -105,7 +105,8 @@ class Create extends Base throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + $function->setAttribute('live', false); + $dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false])); // Inform scheduler to pull the latest changes $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); @@ -113,7 +114,11 @@ class Create extends Base ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $function->getAttribute('schedule')) ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId'))); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'schedule' => $schedule->getAttribute('schedule'), + 'active' => $schedule->getAttribute('active'), + ]))); $response ->setStatusCode(Response::STATUS_CODE_CREATED) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index c447bef81d..5648596826 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -11,6 +11,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -86,7 +87,8 @@ class Delete extends Base $dbForProject->deleteDocument('variables', $variable->getId()); - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + $function->setAttribute('live', false); + $dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false])); // Inform scheduler to pull the latest changes $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); @@ -94,7 +96,11 @@ class Delete extends Base ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $function->getAttribute('schedule')) ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId'))); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'schedule' => $schedule->getAttribute('schedule'), + 'active' => $schedule->getAttribute('active'), + ]))); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index 9c57a3b9c7..acb066ca9c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; @@ -99,12 +100,18 @@ class Update extends Base ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function'])); try { - $dbForProject->updateDocument('variables', $variable->getId(), $variable); + $dbForProject->updateDocument('variables', $variable->getId(), new Document([ + 'key' => $key, + 'value' => $value ?? $variable->getAttribute('value'), + 'secret' => $secret ?? $variable->getAttribute('secret'), + 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']), + ])); } catch (DuplicateException $th) { throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + $function->setAttribute('live', false); + $dbForProject->updateDocument('functions', $function->getId(), new Document(['live' => false])); // Inform scheduler to pull the latest changes $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); @@ -112,7 +119,11 @@ class Update extends Base ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $function->getAttribute('schedule')) ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId'))); - $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'schedule' => $schedule->getAttribute('schedule'), + 'active' => $schedule->getAttribute('active'), + ]))); $response->dynamic($variable, Response::MODEL_VARIABLE); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 09a70bb71d..c080f5d3dd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -255,7 +255,7 @@ class Builds extends Action $version = $this->getVersion($resource); $runtime = $this->getRuntime($resource, $version); - $spec = Config::getParam('specifications')[$resource->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; if ($resource->getCollection() === 'functions' && \is_null($runtime)) { throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); @@ -279,7 +279,10 @@ class Builds extends Action $deployment->setAttribute('buildStartedAt', $startTime); $deployment->setAttribute('status', 'processing'); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'buildStartedAt' => $startTime, + 'status' => 'processing', + ])); if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) { $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); @@ -366,7 +369,11 @@ class Builds extends Action ->setAttribute('sourcePath', $source) ->setAttribute('sourceSize', $directorySize) ->setAttribute('totalSize', $directorySize); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'sourcePath' => $deployment->getAttribute('sourcePath'), + 'sourceSize' => $deployment->getAttribute('sourceSize'), + 'totalSize' => $deployment->getAttribute('totalSize'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -480,7 +487,13 @@ class Builds extends Action $deployment->setAttribute('providerCommitAuthor', APP_VCS_GITHUB_USERNAME); $deployment->setAttribute('providerCommitMessage', "Create '" . $resource->getAttribute('name', '') . "' function"); $deployment->setAttribute('providerCommitUrl', "https://github.com/$cloneOwner/$cloneRepository/commit/$providerCommitHash"); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'providerCommitHash' => $deployment->getAttribute('providerCommitHash'), + 'providerCommitAuthorUrl' => $deployment->getAttribute('providerCommitAuthorUrl'), + 'providerCommitAuthor' => $deployment->getAttribute('providerCommitAuthor'), + 'providerCommitMessage' => $deployment->getAttribute('providerCommitMessage'), + 'providerCommitUrl' => $deployment->getAttribute('providerCommitUrl'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -528,7 +541,11 @@ class Builds extends Action ->setAttribute('sourcePath', $source) ->setAttribute('sourceSize', $directorySize) ->setAttribute('totalSize', $directorySize); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'sourcePath' => $deployment->getAttribute('sourcePath'), + 'sourceSize' => $deployment->getAttribute('sourceSize'), + 'totalSize' => $deployment->getAttribute('totalSize'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -543,7 +560,9 @@ class Builds extends Action /** Request the executor to build the code... */ $deployment->setAttribute('status', 'building'); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'status' => 'building', + ])); if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) { $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); @@ -819,7 +838,9 @@ class Builds extends Action if ($affected) { $deployment = $deployment->setAttribute('buildLogs', $currentLogs); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'buildLogs' => $currentLogs, + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -905,7 +926,14 @@ class Builds extends Action } } - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'buildPath' => $deployment->getAttribute('buildPath'), + 'buildSize' => $deployment->getAttribute('buildSize'), + 'totalSize' => $deployment->getAttribute('totalSize'), + 'buildLogs' => $deployment->getAttribute('buildLogs'), + 'adapter' => $deployment->getAttribute('adapter'), + 'fallbackFile' => $deployment->getAttribute('fallbackFile'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) ->trigger(); @@ -916,12 +944,15 @@ class Builds extends Action $logs = $deployment->getAttribute('buildLogs', ''); $date = \date('H:i:s'); - $logs .= "[$date] [appwrite] Deployment finished. \n"; + $logs .= "\033[90m[$date] \033[90m[\033[0mappwrite\033[90m]\033[32m Deployment finished. \033[0m\n"; $deployment->setAttribute('buildLogs', $logs); /** Update the status */ $deployment->setAttribute('status', 'ready'); - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'buildLogs' => $deployment->getAttribute('buildLogs'), + 'status' => 'ready', + ])); Console::log('Status marked as ready'); @@ -1109,7 +1140,11 @@ class Builds extends Action ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $resource->getAttribute('schedule')) ->setAttribute('active', !empty($resource->getAttribute('schedule')) && !empty($resource->getAttribute('deploymentId'))); - $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule); + $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ + 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), + 'schedule' => $schedule->getAttribute('schedule'), + 'active' => $schedule->getAttribute('active'), + ])); } /** Screenshot site */ @@ -1160,7 +1195,12 @@ class Builds extends Action $deployment->setAttribute('status', 'failed'); $deployment->setAttribute('buildLogs', $message); - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'buildEndedAt' => $deployment->getAttribute('buildEndedAt'), + 'buildDuration' => $deployment->getAttribute('buildDuration'), + 'status' => 'failed', + 'buildLogs' => $message, + ])); if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) { $resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')])); @@ -1189,7 +1229,7 @@ class Builds extends Action protected function sendUsage(Document $resource, Document $deployment, Document $project, StatsUsage $queue): void { - $spec = Config::getParam('specifications')[$resource->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; switch ($deployment->getAttribute('status')) { case 'ready': @@ -1467,7 +1507,9 @@ class Builds extends Action $logs .= "[$date] [appwrite] Git action failed. Deployment will continue. \n"; $deployment->setAttribute('buildLogs', $logs); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'buildLogs' => $deployment->getAttribute('buildLogs'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) @@ -1483,10 +1525,12 @@ class Builds extends Action $logs = $deployment->getAttribute('buildLogs', ''); $date = \date('H:i:s'); - $logs .= "[$date] [appwrite] Build has been canceled. \n"; + $logs .= "\033[90m[$date] \033[90m[\033[0mappwrite\033[90m]\033[33m Build has been canceled. \033[0m\n"; $deployment->setAttribute('buildLogs', $logs); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ + 'buildLogs' => $deployment->getAttribute('buildLogs'), + ])); $queueForRealtime ->setPayload($deployment->getArrayCopy()) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php index 52468cab5a..93c9483959 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Storage; -use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -58,33 +57,21 @@ class Get extends Action $checkStart = \microtime(true); foreach ($devices as $device) { - $uniqueFileName = \uniqid('health', true); - $filePath = $device->getPath($uniqueFileName); + $path = $device->getPath(\uniqid('health', true)); - if (!$device->write($filePath, 'test', 'text/plain')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed writing test file to ' . $device->getRoot()); - } - - $readError = null; try { - if ($device->read($filePath) !== 'test') { - $readError = new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed reading test file from ' . $device->getRoot()); + if (!$device->write($path, 'test', 'text/plain')) { + throw new \Exception("Failed writing test file to {$device->getRoot()}"); + } + + $content = $device->read($path); + if ($content !== 'test') { + throw new \Exception("Failed reading test file from {$device->getRoot()}: content mismatch"); } - } catch (\Throwable $e) { - $readError = $e; } finally { - // Always attempt to clean up test file - if (!$device->delete($filePath)) { - if ($readError !== null) { - // If read already failed, wrap delete error but preserve original - \error_log('Failed deleting test file from ' . $device->getRoot() . ' during read error recovery'); - } else { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed deleting test file from ' . $device->getRoot()); - } - } - // Re-throw read error if it occurred - if ($readError !== null) { - throw $readError; + try { + $device->delete($path); + } catch (\Throwable) { } } } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php index 925416d5d7..f3e47f80ba 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -73,7 +74,7 @@ class Update extends Action ->setAttribute('name', $name) ->setAttribute('expire', $expire); - $dbForPlatform->updateDocument('devKeys', $key->getId(), $key); + $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document(['name' => $name, 'expire' => $expire])); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php index 1a06c1ee84..de11bb0091 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php @@ -11,6 +11,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Validator; @@ -77,9 +78,9 @@ class Update extends Action throw new Exception(Exception::PROJECT_NOT_FOUND); } - $project->setAttribute('labels', (array) \array_values(\array_unique($labels))); + $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php index df5b2b6245..ab92d1a15f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; @@ -72,34 +73,31 @@ class Update extends Action $permissions = $this->getPermissions($teamId, $projectId); - $project - ->setAttribute('teamId', $teamId) - ->setAttribute('teamInternalId', $team->getSequence()) - ->setAttribute('$permissions', $permissions); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'teamId' => $teamId, + 'teamInternalId' => $team->getSequence(), + '$permissions' => $permissions, + ])); $installations = $dbForPlatform->find('installations', [ Query::equal('projectInternalId', [$project->getSequence()]), ]); foreach ($installations as $installation) { - $installation->setAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + $dbForPlatform->updateDocument('installations', $installation->getId(), new Document(['$permissions' => $permissions])); } $repositories = $dbForPlatform->find('repositories', [ Query::equal('projectInternalId', [$project->getSequence()]), ]); foreach ($repositories as $repository) { - $repository->setAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository); + $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['$permissions' => $permissions])); } $vcsComments = $dbForPlatform->find('vcsComments', [ Query::equal('projectInternalId', [$project->getSequence()]), ]); foreach ($vcsComments as $vcsComment) { - $vcsComment->setAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment); + $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), new Document(['$permissions' => $permissions])); } $response->dynamic($project, Response::MODEL_PROJECT); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 0234073882..8a6964209f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -237,7 +237,7 @@ class Create extends Action foreach ($activeDeployments as $activeDeployment) { $activeDeployment->setAttribute('activate', false); - $dbForProject->updateDocument('deployments', $activeDeployment->getId(), $activeDeployment); + $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false])); } } @@ -272,7 +272,12 @@ class Create extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); $sitesDomain = $platform['sitesDomain']; $domain = ID::unique() . "." . $sitesDomain; @@ -302,7 +307,10 @@ class Create extends Action ])) ); } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceSize', $fileSize)->setAttribute('sourceMetadata', $metadata)); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceSize' => $fileSize, + 'sourceMetadata' => $metadata, + ])); } // Start the build @@ -342,7 +350,12 @@ class Create extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), + ])); $sitesDomain = $platform['sitesDomain']; $domain = ID::unique() . "." . $sitesDomain; @@ -368,7 +381,10 @@ class Create extends Action ])) ); } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('sourceChunksUploaded', $chunksUploaded)->setAttribute('sourceMetadata', $metadata)); + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php index 7339c510b5..efea79395f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php @@ -107,11 +107,12 @@ class Delete extends Action $site = $dbForProject->updateDocument( 'sites', $site->getId(), - $site - ->setAttribute('latestDeploymentCreatedAt', $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt()) - ->setAttribute('latestDeploymentInternalId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence()) - ->setAttribute('latestDeploymentId', $latestDeployment->isEmpty() ? '' : $latestDeployment->getId()) - ->setAttribute('latestDeploymentStatus', $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', '')) + new Document([ + 'latestDeploymentCreatedAt' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getCreatedAt(), + 'latestDeploymentInternalId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getSequence(), + 'latestDeploymentId' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getId(), + 'latestDeploymentStatus' => $latestDeployment->isEmpty() ? '' : $latestDeployment->getAttribute('status', ''), + ]) ); } 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 d5918f5f12..546549604b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -142,7 +142,12 @@ class Create extends Action ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), + ])); // Preview deployments for sites $sitesDomain = $platform['sitesDomain']; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Status/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Status/Update.php index 45c842bcd0..7ea5cc87cb 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Status/Update.php @@ -89,7 +89,7 @@ class Update extends Action $endTime = new \DateTime('now'); $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttributes([ + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ 'buildEndedAt' => DateTime::now(), 'buildDuration' => $duration, 'status' => 'canceled' @@ -97,7 +97,9 @@ class Update extends Action if ($deployment->getSequence() === $site->getAttribute('latestDeploymentInternalId', '')) { $site = $site->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), + ])); } try { 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 3b79bedbe7..f648c57a83 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -187,7 +187,12 @@ class Create extends Base ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), $site); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), + ])); $sitesDomain = $platform['sitesDomain']; $domain = ID::unique() . "." . $sitesDomain; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 1da9196980..d01d0d8ca7 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -68,6 +68,7 @@ class Create extends Base ->param('timeout', 30, new Range(1, (int) System::getEnv('_APP_SITES_TIMEOUT', 30)), 'Maximum request time in seconds.', true) ->param('installCommand', '', new Text(8192, 0), 'Install Command.', true) ->param('buildCommand', '', new Text(8192, 0), 'Build Command.', true) + ->param('startCommand', '', new Text(8192, 0), 'Custom start command. Leave empty to use default.', true) ->param('outputDirectory', '', new Text(8192, 0), 'Output Directory for site.', true) ->param('buildRuntime', '', new WhiteList(array_keys(Config::getParam('runtimes')), true), 'Runtime to use during build step.') ->param('adapter', '', new WhiteList(['static', 'ssr']), 'Framework adapter defining rendering strategy. Allowed values are: static, ssr', true) @@ -77,12 +78,19 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) - ->param('specification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) - ), 'Framework specification for the site and builds.', true, ['plan']) + ), 'Build specification for the site deployments.', true, ['plan']) + ->param('runtimeSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + $plan, + Config::getParam('specifications', []), + System::getEnv('_APP_COMPUTE_CPUS', 0), + System::getEnv('_APP_COMPUTE_MEMORY', 0) + ), 'Runtime specification for the SSR executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -100,6 +108,7 @@ class Create extends Base int $timeout, string $installCommand, string $buildCommand, + string $startCommand, string $outputDirectory, string $buildRuntime, string $adapter, @@ -109,7 +118,9 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - string $specification, + string $buildSpecification, + string $runtimeSpecification, + int $deploymentRetention, Response $response, Database $dbForProject, Document $project, @@ -144,13 +155,13 @@ class Create extends Base 'logging' => $logging, 'name' => $name, 'framework' => $framework, + 'deploymentRetention' => $deploymentRetention, 'deploymentInternalId' => '', 'deploymentId' => '', 'timeout' => $timeout, 'installCommand' => $installCommand, 'buildCommand' => $buildCommand, - 'deploymentRetention' => 0, - 'startCommand' => '', + 'startCommand' => $startCommand, 'outputDirectory' => $outputDirectory, 'search' => implode(' ', [$siteId, $name, $framework]), 'fallbackFile' => $fallbackFile, @@ -162,9 +173,8 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'specification' => $specification, - 'buildSpecification' => $specification, - 'runtimeSpecification' => $specification, + 'buildSpecification' => $buildSpecification, + 'runtimeSpecification' => $runtimeSpecification, 'buildRuntime' => $buildRuntime, 'adapter' => $adapter, ]); @@ -193,9 +203,12 @@ class Create extends Base $repository = $dbForPlatform->createDocument('repositories', $repository); $site->setAttribute('repositoryId', $repository->getId()); $site->setAttribute('repositoryInternalId', $repository->getSequence()); - } - $site = $dbForProject->updateDocument('sites', $site->getId(), $site); + $site = $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'repositoryId' => $repository->getId(), + 'repositoryInternalId' => $repository->getSequence(), + ])); + } $queueForEvents->setParam('siteId', $site->getId()); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Deployment/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Deployment/Update.php index 630b9e7c5f..18f80ca53f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Deployment/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Deployment/Update.php @@ -111,7 +111,10 @@ class Update extends Base ->setAttribute('deploymentId', $deployment->getId()) ->setAttribute('deploymentInternalId', $deployment->getSequence()); - $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), $rule)); + $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([ + 'deploymentId' => $rule->getAttribute('deploymentId'), + 'deploymentInternalId' => $rule->getAttribute('deploymentInternalId'), + ]))); }, $queries)); $queueForEvents diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 895e7ae3ef..6510e505ae 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -71,6 +71,7 @@ class Update extends Base ->param('timeout', 30, new Range(1, (int) System::getEnv('_APP_SITES_TIMEOUT', 30)), 'Maximum request time in seconds.', true) ->param('installCommand', '', new Text(8192, 0), 'Install Command.', true) ->param('buildCommand', '', new Text(8192, 0), 'Build Command.', true) + ->param('startCommand', '', new Text(8192, 0), 'Custom start command. Leave empty to use default.', true) ->param('outputDirectory', '', new Text(8192, 0), 'Output Directory for site.', true) ->param('buildRuntime', '', new WhiteList(array_keys(Config::getParam('runtimes')), true), 'Runtime to use during build step.', true) ->param('adapter', '', new WhiteList(['static', 'ssr']), 'Framework adapter defining rendering strategy. Allowed values are: static, ssr', true) @@ -80,12 +81,19 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) - ->param('specification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) - ), 'Framework specification for the site and builds.', true, ['plan']) + ), 'Build specification for the site deployments.', true, ['plan']) + ->param('runtimeSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( + $plan, + Config::getParam('specifications', []), + System::getEnv('_APP_COMPUTE_CPUS', 0), + System::getEnv('_APP_COMPUTE_MEMORY', 0) + ), 'Runtime specification for the SSR executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -107,6 +115,7 @@ class Update extends Base int $timeout, string $installCommand, string $buildCommand, + string $startCommand, string $outputDirectory, string $buildRuntime, string $adapter, @@ -116,7 +125,9 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - string $specification, + string $buildSpecification, + string $runtimeSpecification, + int $deploymentRetention, Request $request, Response $response, Database $dbForProject, @@ -216,6 +227,7 @@ class Update extends Base $site->getAttribute('name') !== $name || $site->getAttribute('buildCommand') !== $buildCommand || $site->getAttribute('installCommand') !== $installCommand || + $site->getAttribute('startCommand') !== $startCommand || $site->getAttribute('outputDirectory') !== $outputDirectory || $site->getAttribute('providerRootDirectory') !== $providerRootDirectory || $site->getAttribute('framework') !== $framework @@ -223,14 +235,22 @@ class Update extends Base $live = false; } - // Enforce Cold Start if spec limits change. - if ($site->getAttribute('specification') !== $specification && !empty($site->getAttribute('deploymentId'))) { - try { - $executor->deleteRuntime($project->getId(), $site->getAttribute('deploymentId')); - } catch (\Throwable $th) { - // Don't throw if the deployment doesn't exist - if ($th->getCode() !== 404) { - throw $th; + if (!empty($site->getAttribute('deploymentId'))) { + $specsChanged = false; + if ($site->getAttribute('runtimeSpecification', '') !== $runtimeSpecification) { + $specsChanged = true; + } elseif ($site->getAttribute('buildSpecification', '') !== $buildSpecification) { + $specsChanged = true; + } + + if ($specsChanged) { + try { + $executor->deleteRuntime($project->getId(), $site->getAttribute('deploymentId')); + } catch (\Throwable $th) { + // Don't throw if the deployment doesn't exist + if ($th->getCode() !== 404) { + throw $th; + } } } } @@ -242,10 +262,10 @@ class Update extends Base 'logging' => $logging, 'live' => $live, 'timeout' => $timeout, + 'deploymentRetention' => $deploymentRetention, 'installCommand' => $installCommand, 'buildCommand' => $buildCommand, - 'deploymentRetention' => 0, - 'startCommand' => '', + 'startCommand' => $startCommand, 'outputDirectory' => $outputDirectory, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), @@ -255,9 +275,8 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'specification' => $specification, - 'buildSpecification' => $specification, - 'runtimeSpecification' => $specification, + 'buildSpecification' => $buildSpecification, + 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$siteId, $name, $framework]), 'buildRuntime' => $buildRuntime, 'adapter' => $adapter, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php index 4a3254ee23..04b30fbc9c 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php @@ -92,7 +92,9 @@ class Create extends Base throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } - $dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false)); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'live' => false, + ])); $response ->setStatusCode(Response::STATUS_CODE_CREATED) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 3e7a2642e6..703806f1aa 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -76,7 +77,9 @@ class Delete extends Base $dbForProject->deleteDocument('variables', $variable->getId()); - $dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false)); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'live' => false, + ])); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 4b16ba17a4..99f68a45df 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -93,12 +94,19 @@ class Update extends Base ->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site'])); try { - $dbForProject->updateDocument('variables', $variable->getId(), $variable); + $dbForProject->updateDocument('variables', $variable->getId(), new Document([ + 'key' => $variable->getAttribute('key'), + 'value' => $variable->getAttribute('value'), + 'secret' => $variable->getAttribute('secret'), + 'search' => $variable->getAttribute('search'), + ])); } catch (DuplicateException $th) { throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } - $dbForProject->updateDocument('sites', $site->getId(), $site->setAttribute('live', false)); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'live' => false, + ])); $response->dynamic($variable, Response::MODEL_VARIABLE); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 282d78ea0c..3bf597eaca 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -266,17 +266,22 @@ class Create extends Action $authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1)); } } elseif ($membership->getAttribute('confirm') === false) { - $membership->setAttribute('secret', $proofForToken->hash($secret)); - $membership->setAttribute('invited', DateTime::now()); + $secretHash = $proofForToken->hash($secret); + $invitedTime = DateTime::now(); if ($isPrivilegedUser || $isAppUser) { - $membership->setAttribute('joined', DateTime::now()); - $membership->setAttribute('confirm', true); + $membership = $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), new Document([ + 'secret' => $secretHash, + 'invited' => $invitedTime, + 'joined' => DateTime::now(), + 'confirm' => true + ]))); + } else { + $membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document([ + 'secret' => $secretHash, + 'invited' => $invitedTime + ])); } - - $membership = ($isPrivilegedUser || $isAppUser) ? - $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) : - $dbForProject->updateDocument('memberships', $membership->getId(), $membership); } else { throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 56f5cd8cb5..3b516c2d60 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -131,7 +131,10 @@ class Delete extends Action if (!$membership->isEmpty()) { $team->setAttribute('userId', $membership->getAttribute('userId')); $team->setAttribute('userInternalId', $membership->getAttribute('userInternalId')); - $dbForProject->updateDocument('teams', $team->getId(), $team); + $dbForProject->updateDocument('teams', $team->getId(), new Document([ + 'userId' => $membership->getAttribute('userId'), + 'userInternalId' => $membership->getAttribute('userInternalId'), + ])); } } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php index eac516c6fe..46b6c3cacf 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php @@ -189,7 +189,7 @@ class Update extends Action ; } - $membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership); + $membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document(['joined' => $membership->getAttribute('joined'), 'confirm' => true])); $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php index 98f342cecd..a935055163 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php @@ -116,7 +116,7 @@ class Update extends Action * Update the roles */ $membership->setAttribute('roles', $roles); - $membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership); + $membership = $dbForProject->updateDocument('memberships', $membership->getId(), new Document(['roles' => $roles])); /** * Replace membership on profile diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php index 4b058c58e1..ebe751ee1c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Name/Update.php @@ -10,6 +10,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Text; @@ -67,7 +68,7 @@ class Update extends Action ->setAttribute('name', $name) ->setAttribute('search', implode(' ', [$teamId, $name])); - $team = $dbForProject->updateDocument('teams', $team->getId(), $team); + $team = $dbForProject->updateDocument('teams', $team->getId(), new Document(['name' => $name, 'search' => implode(' ', [$teamId, $name])])); $queueForEvents->setParam('teamId', $team->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 6b521e56d0..4a34ffd36a 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 @@ -98,9 +98,8 @@ class Update extends Action } $providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId])); - $repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds); - $repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository)); + $repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds]))); $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index cb45efbf39..914bcaa93e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -152,7 +152,13 @@ class Get extends Action ->setAttribute('personalRefreshToken', $refreshToken) ->setAttribute('personalAccessToken', $accessToken) ->setAttribute('personalAccessTokenExpiry', $accessTokenExpiry); - $installation = $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + $installation = $dbForPlatform->updateDocument('installations', $installation->getId(), new Document([ + 'organization' => $installation->getAttribute('organization'), + 'personal' => $installation->getAttribute('personal'), + 'personalRefreshToken' => $installation->getAttribute('personalRefreshToken'), + 'personalAccessToken' => $installation->getAttribute('personalAccessToken'), + 'personalAccessTokenExpiry' => $installation->getAttribute('personalAccessTokenExpiry'), + ])); } } else { $error = 'Installation of the Appwrite GitHub App on organization accounts is restricted to organization owners. As a member of the organization, you do not have the necessary permissions to install this GitHub App. Please contact the organization owner to create the installation from the Appwrite console.'; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 6d493e2cdb..c9904bb32b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -326,7 +326,12 @@ trait Deployment ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), $resource)); + $authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document([ + 'latestDeploymentId' => $resource->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $resource->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $resource->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $resource->getAttribute('latestDeploymentStatus'), + ]))); if ($resource->getCollection() === 'sites') { $projectId = $project->getId(); 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 de6750fe64..c614c80041 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; @@ -235,7 +236,7 @@ class Create extends Action if (\in_array($providerPullRequestId, $providerPullRequestIds)) { $providerPullRequestIds = \array_diff($providerPullRequestIds, [$providerPullRequestId]); $repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds); - $repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository)); + $repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds]))); } } } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index bba062a730..04003812f8 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -114,7 +114,11 @@ class Create extends Action ->setAttribute('personalRefreshToken', $refreshToken) ->setAttribute('personalAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$oauth2->getAccessTokenExpiry(''))); - $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + $dbForPlatform->updateDocument('installations', $installation->getId(), new Document([ + 'personalAccessToken' => $installation->getAttribute('personalAccessToken'), + 'personalRefreshToken' => $installation->getAttribute('personalRefreshToken'), + 'personalAccessTokenExpiry' => $installation->getAttribute('personalAccessTokenExpiry'), + ])); } try { diff --git a/src/Appwrite/Platform/Services/Tasks.php b/src/Appwrite/Platform/Services/Tasks.php index 941530d7ed..023cade694 100644 --- a/src/Appwrite/Platform/Services/Tasks.php +++ b/src/Appwrite/Platform/Services/Tasks.php @@ -16,6 +16,7 @@ use Appwrite\Platform\Tasks\SDKs; use Appwrite\Platform\Tasks\Specs; use Appwrite\Platform\Tasks\SSL; use Appwrite\Platform\Tasks\StatsResources; +use Appwrite\Platform\Tasks\TimeTravel; use Appwrite\Platform\Tasks\Upgrade; use Appwrite\Platform\Tasks\Vars; use Appwrite\Platform\Tasks\Version; @@ -44,6 +45,7 @@ class Tasks extends Service ->addAction(Vars::getName(), new Vars()) ->addAction(Version::getName(), new Version()) ->addAction(StatsResources::getName(), new StatsResources()) + ->addAction(TimeTravel::getName(), new TimeTravel()) ; } } diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index 38fc611465..a7d16e0a52 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -159,9 +159,7 @@ class Interval extends Action } foreach ($staleExecutions as $execution) { - $execution->setAttribute('status', 'failed'); - $execution->setAttribute('errors', 'Execution timed out'); - $dbForProject->updateDocument('executions', $execution->getId(), $execution); + $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out'])); } $processed++; diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index 2dace58d1d..c821435786 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -13,6 +13,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\System\System; +use Utopia\Validator\WhiteList; class Maintenance extends Action { @@ -25,6 +26,7 @@ class Maintenance extends Action { $this ->desc('Schedules maintenance tasks and publishes them to our queues') + ->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.') ->inject('dbForPlatform') ->inject('console') ->inject('queueForCertificates') @@ -32,7 +34,7 @@ class Maintenance extends Action ->callback($this->action(...)); } - public function action(Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void + public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -57,9 +59,7 @@ class Maintenance extends Action $delay = $next->getTimestamp() - $now->getTimestamp(); } - Console::info('Setting loop start time to ' . $next->format("Y-m-d H:i:s.v") . '. Delaying for ' . $delay . ' seconds.'); - - Console::loop(function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { + $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { $time = DatabaseDateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -96,7 +96,17 @@ class Maintenance extends Action $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); $this->notifyDeleteCSVExports($queueForDeletes); - }, $interval, $delay); + }; + + if ($type === 'loop') { + Console::info('Setting loop start time to ' . $next->format("Y-m-d H:i:s.v") . '. Delaying for ' . $delay . ' seconds.'); + + Console::loop(function () use ($action) { + $action(); + }, $interval, $delay); + } elseif ($type === 'trigger') { + $action(); + } } private function notifyDeleteConnections(Delete $queueForDeletes): void diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 949dbb3e6b..528084f4ea 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -25,6 +25,7 @@ use Appwrite\SDK\Language\Swift; use Appwrite\SDK\Language\Web; use Appwrite\SDK\SDK; use Appwrite\Spec\Swagger2; +use CzProject\GitPhp\Git; use Utopia\Agents\Adapters\OpenAI; use Utopia\Agents\DiffCheck\DiffCheck; use Utopia\Agents\DiffCheck\Options as DiffCheckOptions; @@ -41,29 +42,6 @@ use Utopia\Validator\WhiteList; class SDKs extends Action { - protected array $supportedSDKS = [ - 'web', - 'cli', - 'php', - 'nodejs', - 'deno', - 'python', - 'ruby', - 'flutter', - 'react-native', - 'dart', - 'go', - 'swift', - 'apple', - 'dotnet', - 'android', - 'graphql', - 'rest', - 'markdown', - 'agent-skills', - 'cursor-plugin' - ]; - public static function getName(): string { return 'sdks'; @@ -74,6 +52,19 @@ class SDKs extends Action return Specs::getPlatforms(); } + protected function getSdkConfigPath(): string + { + return __DIR__ . '/../../../../app/config/sdks.php'; + } + + protected function getSupportedSDKs(): array + { + return \array_unique(\array_merge(...\array_values(\array_map( + fn ($platform) => \array_column($platform['sdks'], 'key'), + Config::getParam('sdks') + )))); + } + public function __construct() { $this @@ -100,8 +91,9 @@ class SDKs extends Action if (! $sdks) { $selectedPlatform ??= Console::confirm('Choose Platform ("' . implode('", "', static::getPlatforms()) . '" or "*" for all):'); $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); - if ($selectedSDK !== '*' && ! \in_array($selectedSDK, $this->supportedSDKS)) { - throw new \Exception('Unknown SDK "' . $selectedSDK . '" given. Options are: ' . implode(', ', $this->supportedSDKS)); + $supportedSDKs = $this->getSupportedSDKs(); + if ($selectedSDK !== '*' && ! \in_array($selectedSDK, $supportedSDKs)) { + throw new \Exception('Unknown SDK "' . $selectedSDK . '" given. Options are: ' . implode(', ', $supportedSDKs)); } } else { $sdks = explode(',', $sdks); @@ -117,9 +109,6 @@ class SDKs extends Action $prUrls = []; - if ($git) { - $message ??= Console::confirm('Please enter your commit message:'); - } } elseif ($examplesOnly) { $git = false; $prUrls = []; @@ -337,7 +326,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } // Check if release already exists - $checkReleaseCommand = 'gh release view "' . $releaseVersion . '" --repo "' . $repoName . '" --json url --jq ".url" 2>/dev/null'; + $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null'; $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? ''); if (! empty($existingReleaseUrl)) { @@ -368,7 +357,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } $previousVersion = ''; - $tagListCommand = 'gh release list --repo "' . $repoName . '" --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; + $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; $previousVersion = trim(\shell_exec($tagListCommand) ?? ''); $formattedNotes = "## What's Changed\n\n"; @@ -396,11 +385,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_'); \file_put_contents($tempNotesFile, $formattedNotes); - $releaseCommand = 'gh release create "' . $releaseVersion . '" \ - --repo "' . $repoName . '" \ - --title "' . $releaseTitle . '" \ - --notes-file "' . $tempNotesFile . '" \ - --target "' . $releaseTarget . '" \ + $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \ + --repo ' . \escapeshellarg($repoName) . ' \ + --title ' . \escapeshellarg($releaseTitle) . ' \ + --notes-file ' . \escapeshellarg($tempNotesFile) . ' \ + --target ' . \escapeshellarg($releaseTarget) . ' \ 2>&1'; $releaseOutput = []; @@ -491,7 +480,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND Console::info("Analyzing SDK changes with AI..."); $aiResult = $this->generateVersionAndChangelog($language, $result); - if ($aiResult !== null) { + if (!empty($aiResult['skip'])) { + Console::warning("Skipping {$language['name']} SDK generation"); + continue; + } elseif ($aiResult !== null) { $newVersion = $aiResult['version']; $newChangelog = $aiResult['changelog']; $aiChangelog = $newChangelog; // Store for PR description @@ -499,19 +491,17 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // Update the version in the config $this->updateSdkVersion($key, $language['key'], $newVersion); - // Update the changelog file + // Update the source changelog file $this->updateChangelogFile($language['changelog'], $newVersion, $newChangelog); - // Also update CHANGELOG.md in the generated SDK directory - $sdkChangelogPath = $result . '/CHANGELOG.md'; - if (file_exists($sdkChangelogPath)) { - $this->updateChangelogFile($sdkChangelogPath, $newVersion, $newChangelog); - } + // Re-read updated changelog so regeneration includes the new entry + $updatedChangelog = \file_get_contents($language['changelog']); + $sdk->setChangelog($updatedChangelog); // Reload the language config with updated values $language['version'] = $newVersion; - // Regenerate SDK with new version + // Regenerate SDK with new version and updated changelog $sdk->setVersion($newVersion); try { $sdk->generate($result); @@ -527,159 +517,26 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $gitBranch = $language['gitBranch']; $repoBranch = $language['repoBranch'] ?? 'main'; - if ($git && ! empty($gitUrl)) { - Console::info("Preparing {$language['name']} SDK repository..."); - - \exec('rm -rf ' . $target . ' && \ - mkdir -p ' . $target . ' && \ - cd ' . $target . ' && \ - git init --quiet && \ - git config core.ignorecase false && \ - git config pull.rebase false && \ - git config advice.defaultBranchName false && \ - git remote add origin ' . $gitUrl . ' && \ - git fetch origin --quiet --no-tags --depth 1 ' . $repoBranch . ' 2>&1 | grep -v "^remote:" | grep -v "^From " | grep -v "^ \* " || true && \ - (git checkout -f ' . $repoBranch . ' 2>/dev/null || git checkout -b ' . $repoBranch . ') && \ - git pull origin ' . $repoBranch . ' --quiet --no-tags 2>&1 | grep -v "^From " | grep -v "^ \* " || true && \ - (git checkout -f ' . $gitBranch . ' 2>/dev/null || git checkout -b ' . $gitBranch . ') && \ - (git fetch origin ' . $gitBranch . ' --quiet --no-tags --depth 1 2>/dev/null || git push -u origin ' . $gitBranch . ' --quiet 2>&1 | grep -v "^remote:" || true) && \ - git reset --hard origin/' . $gitBranch . ' 2>/dev/null || true && \ - (if [ -d .github ]; then cp -r .github /tmp/.github-backup-$$ 2>/dev/null; fi) && \ - git rm -rf --cached . 2>/dev/null && \ - git clean -fdx -e .git -e .github 2>/dev/null && \ - cp -r ' . $result . '/. ' . $target . '/ && \ - (if [ -d /tmp/.github-backup-$$/.github ]; then cp -rn /tmp/.github-backup-$$/.github . 2>/dev/null && rm -rf /tmp/.github-backup-$$; fi) && \ - git add -A && \ - git commit -m "' . $message . '" --quiet && \ - git push -u origin ' . $gitBranch . ' --quiet 2>&1 | grep -E "^(To | |[0-9a-f]+\\.\\.[0-9a-f]+)" || true - ', $gitOutput, $gitReturnCode); - - if ($gitReturnCode !== 0) { - Console::warning("Git operations completed with warnings (exit code: {$gitReturnCode})"); + if ($git && !empty($gitUrl)) { + // Generate commit message: use provided message, AI changelog, or fallback + if (! empty($message)) { + $commitMessage = $message; + } elseif (! empty($aiChangelog) && $aiChangelog !== '* No user-facing SDK changes.') { + $commitMessage = "feat: update {$language['name']} SDK to {$language['version']}\n\n{$aiChangelog}"; + } else { + $commitMessage = "chore: update {$language['name']} SDK to {$language['version']}"; } - Console::success("Pushed {$language['name']} SDK to {$gitUrl}"); - if ($git) { - $prTitle = "feat: {$language['name']} SDK update for version {$language['version']}"; + $pushSuccess = $this->pushToGit($language, $target, $result, $gitUrl, $gitBranch, $repoBranch, $commitMessage); - // Build PR body with AI changelog if available - $prBody = "This PR contains updates to the {$language['name']} SDK for version {$language['version']}."; - if (!empty($aiChangelog) && $aiChangelog !== '* No user-facing SDK changes.') { - $prBody .= "\n\n## Changes\n\n{$aiChangelog}"; - } - $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; - - Console::info("Creating pull request for {$language['name']} SDK..."); - - $prCommand = 'cd ' . $target . ' && \ - gh pr create \ - --repo "' . $repoName . '" \ - --title "' . $prTitle . '" \ - --body "' . $prBody . '" \ - --base "' . $repoBranch . '" \ - --head "' . $gitBranch . '" \ - 2>&1'; - - $prOutput = []; - $prReturnCode = 0; - \exec($prCommand, $prOutput, $prReturnCode); - - if ($prReturnCode === 0) { - Console::success("Successfully created pull request for {$language['name']} SDK"); - if (! empty($prOutput)) { - $prUrls[$language['name']] = end($prOutput); - } - } else { - $errorMessage = implode("\n", $prOutput); - if (strpos($errorMessage, 'already exists') !== false) { - Console::warning("Pull request already exists for {$language['name']} SDK, updating title and body..."); - $prNumberCommand = 'cd ' . $target . ' && \ - gh pr list \ - --repo "' . $repoName . '" \ - --head "' . $gitBranch . '" \ - --json number \ - --jq ".[0].number" \ - 2>&1'; - - $prNumberOutput = []; - $prNumberReturnCode = 0; - \exec($prNumberCommand, $prNumberOutput, $prNumberReturnCode); - - if ($prNumberReturnCode === 0 && ! empty($prNumberOutput[0])) { - $prNumber = trim($prNumberOutput[0]); - - // Use API directly to update PR to avoid deprecated projectCards field - $updateCommand = 'cd ' . $target . ' && \ - gh api \ - --method PATCH \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/' . $repoName . '/pulls/' . $prNumber . ' \ - -f title="' . $prTitle . '" \ - -f body="' . $prBody . '" \ - 2>&1'; - - $updateOutput = []; - $updateReturnCode = 0; - \exec($updateCommand, $updateOutput, $updateReturnCode); - - if ($updateReturnCode === 0) { - Console::success("Successfully updated pull request for {$language['name']} SDK"); - - $prUrlCommand = 'cd ' . $target . ' && \ - gh pr list \ - --repo "' . $repoName . '" \ - --head "' . $gitBranch . '" \ - --json url \ - --jq ".[0].url" \ - 2>&1'; - - $prUrlOutput = []; - $prUrlReturnCode = 0; - \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode); - - if ($prUrlReturnCode === 0 && ! empty($prUrlOutput)) { - $prUrls[$language['name']] = trim($prUrlOutput[0]); - } - } else { - $updateErrorMessage = implode("\n", $updateOutput); - Console::error("Failed to update pull request for {$language['name']} SDK: " . $updateErrorMessage); - } - } else { - Console::error("Failed to get PR number for {$language['name']} SDK"); - } - } else { - Console::error("Failed to create pull request for {$language['name']} SDK: " . $errorMessage); - } - } + if ($pushSuccess) { + $this->createPullRequest($language, $target, $gitBranch, $repoBranch, $aiChangelog, $prUrls); } - \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target); - Console::success("Remove temp directory '{$target}' for {$language['name']} SDK"); + $this->cleanupTarget($target, $language['name']); } - $docDirectories = $language['docDirectories'] ?? ['']; - - if ($version === 'latest') { - continue; - } - - foreach ($docDirectories as $languageTitle => $path) { - $languagePath = strtolower($languageTitle !== 0 ? '/' . $languageTitle : ''); - $examplesSource = $result . '/docs/examples' . $languagePath; - - if (! \is_dir($examplesSource)) { - Console::warning("No code examples found for {$language['name']} SDK at: {$examplesSource}. Skipping copy."); - - continue; - } - - \exec( - 'mkdir -p ' . $resultExamples . $languagePath . ' && \ - cp -r ' . $examplesSource . ' ' . $resultExamples - ); - Console::success("Copied code examples for {$language['name']} SDK to: {$resultExamples}"); - } + $this->copyExamples($language, $version, $result, $resultExamples); } } @@ -693,6 +550,176 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } } + private function pushToGit(array $language, string $target, string $result, string $gitUrl, string $gitBranch, string $repoBranch, string $commitMessage): bool + { + Console::info("Preparing {$language['name']} SDK repository..."); + + try { + // Init fresh repo + \exec('rm -rf ' . \escapeshellarg($target)); + \mkdir($target, 0755, true); + + $gitClient = new Git(); + $repo = $gitClient->init($target); + + $repo->execute('config', 'core.ignorecase', 'false'); + $repo->execute('config', 'pull.rebase', 'false'); + $repo->execute('config', 'advice.defaultBranchName', 'false'); + $repo->addRemote('origin', $gitUrl); + + // Fetch and checkout base branch (or create if new repo) + try { + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); + try { + $repo->execute('checkout', '-f', $repoBranch); + } catch (\Throwable) { + $repo->execute('checkout', '-b', $repoBranch); + } + } catch (\Throwable) { + $repo->execute('checkout', '-b', $repoBranch); + } + + try { + $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags'); + } catch (\Throwable) { + } + + // Checkout dev branch (or create if it doesn't exist) + try { + $repo->execute('checkout', '-f', $gitBranch); + } catch (\Throwable) { + $repo->execute('checkout', '-b', $gitBranch); + } + + // Fetch dev branch, or push to create it on remote + try { + $repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1'); + } catch (\Throwable) { + try { + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); + } catch (\Throwable) { + } + } + + // Sync with remote dev branch + try { + $repo->execute('reset', '--hard', "origin/{$gitBranch}"); + } catch (\Throwable) { + } + + // Backup .github before cleaning working tree + $githubDir = $target . '/.github'; + $githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid(); + $hasGithubDir = \is_dir($githubDir); + if ($hasGithubDir) { + \exec('cp -r ' . \escapeshellarg($githubDir) . ' ' . \escapeshellarg($githubBackup)); + } + + // Clean working tree + try { + $repo->execute('rm', '-rf', '--cached', '.'); + } catch (\Throwable) { + } + try { + $repo->execute('clean', '-fdx', '-e', '.git', '-e', '.github'); + } catch (\Throwable) { + } + + // Copy generated SDK and restore .github + \exec('cp -r ' . \escapeshellarg($result . '/.') . ' ' . \escapeshellarg($target . '/')); + + if ($hasGithubDir && \is_dir($githubBackup)) { + \exec('cp -rn ' . \escapeshellarg($githubBackup . '/.github') . ' ' . \escapeshellarg($target . '/') . ' 2>/dev/null'); + \exec('rm -rf ' . \escapeshellarg($githubBackup)); + } + + // Stage, commit, push + $repo->addAllChanges(); + $repo->commit($commitMessage); + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); + } catch (\Throwable $e) { + Console::warning("Git operations failed for {$language['name']} SDK: " . $e->getMessage()); + return false; + } + + Console::success("Pushed {$language['name']} SDK to {$gitUrl}"); + return true; + } + + private function createPullRequest(array $language, string $target, string $gitBranch, string $repoBranch, string $aiChangelog, array &$prUrls): void + { + $prTitle = "feat: {$language['name']} SDK update for version {$language['version']}"; + $prBody = "This PR contains updates to the {$language['name']} SDK for version {$language['version']}."; + if (!empty($aiChangelog) && $aiChangelog !== '* No user-facing SDK changes.') { + $prBody .= "\n\n## Changes\n\n{$aiChangelog}"; + } + $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; + + Console::info("Creating pull request for {$language['name']} SDK..."); + + $prCommand = 'cd ' . $target . ' && \ + gh pr create \ + --repo ' . \escapeshellarg($repoName) . ' \ + --title ' . \escapeshellarg($prTitle) . ' \ + --body ' . \escapeshellarg($prBody) . ' \ + --base ' . \escapeshellarg($repoBranch) . ' \ + --head ' . \escapeshellarg($gitBranch) . ' \ + 2>&1'; + + $prOutput = []; + $prReturnCode = 0; + \exec($prCommand, $prOutput, $prReturnCode); + + if ($prReturnCode === 0) { + Console::success("Successfully created pull request for {$language['name']} SDK"); + foreach ($prOutput as $line) { + if (\str_starts_with(trim($line), 'https://')) { + $prUrls[$language['name']] = trim($line); + break; + } + } + } else { + $errorMessage = implode("\n", $prOutput); + if (strpos($errorMessage, 'already exists') === false) { + Console::error("Failed to create pull request for {$language['name']} SDK: " . $errorMessage); + } else { + $this->updateExistingPr($target, $repoName, $gitBranch, $prTitle, $prBody, $language['name'], $prUrls); + } + } + } + + private function cleanupTarget(string $target, string $languageName): void + { + \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target); + Console::success("Remove temp directory '{$target}' for {$languageName} SDK"); + } + + private function copyExamples(array $language, string $version, string $result, string $resultExamples): void + { + $docDirectories = $language['docDirectories'] ?? ['']; + + if ($version === 'latest') { + return; + } + + foreach ($docDirectories as $languageTitle => $path) { + $languagePath = strtolower($languageTitle !== 0 ? '/' . $languageTitle : ''); + $examplesSource = $result . '/docs/examples' . $languagePath; + + if (! \is_dir($examplesSource)) { + Console::warning("No code examples found for {$language['name']} SDK at: {$examplesSource}. Skipping copy."); + + continue; + } + + \exec( + 'mkdir -p ' . $resultExamples . $languagePath . ' && \ + cp -r ' . $examplesSource . ' ' . $resultExamples + ); + Console::success("Copied code examples for {$language['name']} SDK to: {$resultExamples}"); + } + } + /** * Extract release notes from changelog for a specific version */ @@ -780,20 +807,25 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND required: $object->getNames() ); + $isBeta = !empty($language['beta']); + $betaNote = $isBeta + ? "\n Note: This SDK is in beta (version < 1.0.0). Do NOT bump to 1.0.0. Use `minor` for both breaking changes and new features, `patch` for bug fixes only." + : ''; + $prompt = <<= 1.0.0 + if ($isBeta && ($parsed['versionBump'] === 'major' || \version_compare($parsed['version'], '1.0.0', '>='))) { + Console::warning("Beta SDK {$language['name']} cannot have a major bump or version >= 1.0.0 (AI suggested {$parsed['version']}), skipping"); + return ['skip' => true]; + } + Console::success("✓ Analysis complete"); Console::log(" Version: {$language['version']} → {$parsed['version']} ({$parsed['versionBump']} bump)"); Console::log(" Changelog:"); @@ -891,21 +928,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ]; } catch (\Throwable $e) { Console::error('Error generating version and changelog: ' . $e->getMessage()); - return null; } } - /** - * Get the SDK config file path - * - * @return string Path to the SDK config file - */ - protected function getSdkConfigPath(): string - { - return __DIR__ . '/../../../../app/config/sdks.php'; - } - /** * Update SDK version in the config file * @@ -920,7 +946,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! file_exists($configPath)) { Console::error("Config file not found: {$configPath}"); - return false; } @@ -936,11 +961,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (file_put_contents($configPath, $newContent) !== false) { Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config"); - return true; } else { Console::error('Failed to write config file'); - return false; } } @@ -956,11 +979,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (file_put_contents($configPath, $newContent) !== false) { Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config"); - return true; } else { Console::error('Failed to write config file'); - return false; } } @@ -1019,12 +1040,71 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (file_put_contents($changelogPath, $newContent) !== false) { Console::success("Updated changelog at {$changelogPath} with version {$version}"); - return true; } else { Console::error('Failed to write changelog file'); - return false; } } + + private function updateExistingPr(string $target, string $repoName, string $gitBranch, string $prTitle, string $prBody, string $sdkName, array &$prUrls): void + { + Console::warning("Pull request already exists for {$sdkName} SDK, updating title and body..."); + + $prNumberCommand = 'cd ' . $target . ' && \ + gh pr list \ + --repo ' . \escapeshellarg($repoName) . ' \ + --head ' . \escapeshellarg($gitBranch) . ' \ + --json number \ + --jq ".[0].number" \ + 2>&1'; + + $prNumberOutput = []; + $prNumberReturnCode = 0; + \exec($prNumberCommand, $prNumberOutput, $prNumberReturnCode); + + if ($prNumberReturnCode !== 0 || empty($prNumberOutput[0])) { + Console::error("Failed to get PR number for {$sdkName} SDK"); + return; + } + + $prNumber = trim($prNumberOutput[0]); + $apiPath = "/repos/{$repoName}/pulls/{$prNumber}"; + $updateCommand = 'cd ' . $target . ' && \ + gh api \ + --method PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + ' . \escapeshellarg($apiPath) . ' \ + -f title=' . \escapeshellarg($prTitle) . ' \ + -f body=' . \escapeshellarg($prBody) . ' \ + 2>&1'; + + $updateOutput = []; + $updateReturnCode = 0; + \exec($updateCommand, $updateOutput, $updateReturnCode); + + if ($updateReturnCode !== 0) { + Console::error("Failed to update pull request for {$sdkName} SDK: " . implode("\n", $updateOutput)); + return; + } + + Console::success("Successfully updated pull request for {$sdkName} SDK"); + + $prUrlCommand = 'cd ' . $target . ' && \ + gh pr list \ + --repo ' . \escapeshellarg($repoName) . ' \ + --head ' . \escapeshellarg($gitBranch) . ' \ + --json url \ + --jq ".[0].url" \ + 2>&1'; + + $prUrlOutput = []; + $prUrlReturnCode = 0; + \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode); + + if ($prUrlReturnCode === 0 && ! empty($prUrlOutput)) { + $prUrls[$sdkName] = trim($prUrlOutput[0]); + } + } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index cfcdc2503e..c55e3d4a6a 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -59,8 +59,11 @@ abstract class ScheduleBase extends Action if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { - $project->setAttribute('accessedAt', DateTime::now()); - $dbForPlatform->updateDocument('projects', $project->getId(), $project); + $now = DateTime::now(); + $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'accessedAt' => $now + ])); + $project->setAttribute('accessedAt', $now); } } } diff --git a/src/Appwrite/Platform/Tasks/TimeTravel.php b/src/Appwrite/Platform/Tasks/TimeTravel.php new file mode 100644 index 0000000000..323db56806 --- /dev/null +++ b/src/Appwrite/Platform/Tasks/TimeTravel.php @@ -0,0 +1,72 @@ +desc('Create a time-travel to change $createdAt') + ->param('projectId', '', new UID(), 'Project ID.') + ->param('resourceType', '', new WhiteList(['deployment']), 'Type of resource.') + ->param('resourceId', '', new UID(), 'ID of resource.') + ->param('createdAt', '', new DatetimeValidator(), 'New value for $createdAt') + ->inject('getProjectDB') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $resourceType, string $resourceId, string $createdAt, callable $getProjectDB, Database $dbForPlatform): void + { + $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; + + if (!$isDevelopment) { + Console::error('This task is only available in development mode.'); + return; + } + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + Console::error('Project not found.'); + return; + } + + $collection = match ($resourceType) { + 'deployment' => 'deployments', + default => throw new \Exception('Resource type not implemented') + }; + + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + $resource = $dbForProject->getDocument($collection, $resourceId); + if ($resource->isEmpty()) { + Console::error('Resource not found.'); + return; + } + + $update = new Document([ + '$createdAt' => $createdAt, + ]); + + $dbForProject->withPreserveDates(fn () => $dbForProject->updateDocument($collection, $resourceId, $update)); + + Console::success('Time-travel successful. Updated $createdAt for ' . $resourceType . ' ' . $resourceId . ' to ' . $createdAt); + } +} diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 7de67a2b66..73509819a9 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -423,7 +423,11 @@ class Certificates extends Action Func $queueForFunctions, Realtime $queueForRealtime ): void { - $rule = $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); + $rule = $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([ + 'status' => $rule->getAttribute('status'), + 'certificateId' => $rule->getAttribute('certificateId'), + 'logs' => $rule->getAttribute('logs'), + ])); $projectId = $rule->getAttribute('projectId'); // Skip events for console project (triggered by auto-ssl generation for 1 click setups) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a211ec12fd..500d42f037 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Workers; use Appwrite\Certificates\Adapter as CertificatesAdapter; use Appwrite\Deletes\Identities; use Appwrite\Deletes\Targets; +use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Extend\Exception; use Executor\Executor; use Throwable; @@ -53,6 +54,7 @@ class Deletes extends Action ->inject('project') ->inject('dbForPlatform') ->inject('getProjectDB') + ->inject('getDatabasesDB') ->inject('getLogsDB') ->inject('deviceForFiles') ->inject('deviceForFunctions') @@ -65,6 +67,7 @@ class Deletes extends Action ->inject('executionsRetentionCount') ->inject('auditRetention') ->inject('log') + ->inject('queueForDeletes') ->inject('getAudit') ->callback($this->action(...)); } @@ -78,6 +81,7 @@ class Deletes extends Action Document $project, Database $dbForPlatform, callable $getProjectDB, + callable $getDatabasesDB, callable $getLogsDB, Device $deviceForFiles, Device $deviceForFunctions, @@ -90,6 +94,7 @@ class Deletes extends Action int $executionsRetentionCount, string $auditRetention, Log $log, + DeleteEvent $queueForDeletes, callable $getAudit, ): void { $payload = $message->getPayload() ?? []; @@ -112,7 +117,7 @@ class Deletes extends Action case DELETE_TYPE_DOCUMENT: switch ($document->getCollection()) { case DELETE_TYPE_PROJECTS: - $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document); + $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document); break; case DELETE_TYPE_SITES: $this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); @@ -147,7 +152,7 @@ class Deletes extends Action } break; case DELETE_TYPE_TEAM_PROJECTS: - $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $certificates, $document); + $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $getDatabasesDB, $certificates, $document); break; case DELETE_TYPE_EXECUTIONS: $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention); @@ -210,12 +215,57 @@ class Deletes extends Action $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); + $this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB); break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); } } + private function cleanDatabase( + Document $databaseDoc, + callable $executionActionPerDatabase, + bool $projectTables, + array $projectCollectionIds + ): void { + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => $this->cleanDatabaseCollections( + $dbForDatabases, + $projectTables, + $projectCollectionIds + ) + ); + } + + private function cleanDatabaseCollections( + Database $dbForDatabases, + bool $projectTables, + array $projectCollectionIds + ): void { + $dbForDatabases->foreach( + Database::METADATA, + function (Document $collection) use ($dbForDatabases, $projectTables, $projectCollectionIds) { + $collectionId = $collection->getId(); + + try { + if ($projectTables || !\in_array($collectionId, $projectCollectionIds, true)) { + $dbForDatabases->deleteCollection($collectionId); + return; + } + + $this->deleteByGroup( + $collectionId, + [Query::orderAsc()], + database: $dbForDatabases + ); + } catch (Throwable $e) { + Console::error('Error deleting ' . $collectionId . ' ' . $e->getMessage()); + } + } + ); + } + /** * @param Database $dbForPlatform * @param callable $getProjectDB @@ -327,6 +377,61 @@ class Deletes extends Action Targets::delete($getProjectDB($project), Query::equal('sessionInternalId', [$session->getSequence()])); } + private function deleteOldDeployments(DeleteEvent $queueForDeletes, Document $project, callable $getProjectDB): void + { + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + $removalCallback = function (Document $resource) use ($dbForProject, $queueForDeletes, $project) { + $retention = $resource->getAttribute('deploymentRetention', 0); + + // 0 means unlimited - never delete + if ($retention === 0) { + return; + } + + $activeDeploymentId = $resource->getAttribute('deploymentId', ''); + + $queries = [ + Query::createdBefore(DateTime::addSeconds(new \DateTime(), -1 * $retention * 24 * 60 * 60)), + Query::equal('resourceInternalId', [$resource->getSequence()]), + Query::equal('resourceType', [$resource->getCollection()]), + Query::orderDesc('$createdAt'), + ]; + + if (!empty($activeDeploymentId)) { + $queries[] = Query::notEqual('$id', $activeDeploymentId); + } + + $this->deleteByGroup( + 'deployments', + $queries, + $dbForProject, + function (Document $deployment) use ($queueForDeletes, $project) { + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($deployment) + ->setProject($project) + ->trigger(); + } + ); + }; + + $this->listByGroup( + 'functions', + [], + $dbForProject, + $removalCallback + ); + + $this->listByGroup( + 'sites', + [], + $dbForProject, + $removalCallback + ); + } + /** * @param Document $project * @param callable $getProjectDB @@ -488,7 +593,7 @@ class Deletes extends Action * @throws Structure * @throws Exception */ - protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, CertificatesAdapter $certificates, Document $document): void + protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, CertificatesAdapter $certificates, Document $document): void { $projects = $dbForPlatform->find('projects', [ @@ -503,7 +608,7 @@ class Deletes extends Action $deviceForBuilds = getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()); $deviceForCache = getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()); - $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project); + $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project); $dbForPlatform->deleteDocument('projects', $project->getId()); } } @@ -521,7 +626,7 @@ class Deletes extends Action * @throws Authorization * @throws DatabaseException */ - protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void + protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void { $projectInternalId = $document->getSequence(); $projectId = $document->getId(); @@ -558,23 +663,44 @@ class Deletes extends Action $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $dbForProject->foreach(Database::METADATA, function (Document $collection) use ($dbForProject, $projectTables, $projectCollectionIds) { - try { - if ($projectTables || !\in_array($collection->getId(), $projectCollectionIds)) { - $dbForProject->deleteCollection($collection->getId()); - } else { - $this->deleteByGroup( - $collection->getId(), - [ - Query::orderAsc() - ], - database: $dbForProject - ); - } - } catch (Throwable $e) { - Console::error('Error deleting ' . $collection->getId() . ' ' . $e->getMessage()); + $allDatabases = [ + new Document([ + 'database' => $document->getAttribute('database') + ]), + ...$dbForProject->find('databases', [ + Query::equal('type', [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]), + Query::limit(5000), + ]), + ]; + $databasesToClean = []; + + foreach ($allDatabases as $db) { + $key = $db->getAttribute('database'); + + if ($key) { + $databasesToClean[$key] ??= $db; } - }); + } + + $databasesToClean = array_values($databasesToClean); + + $executionActionPerDatabase = function (Document $databaseDoc, $callback) use ($getDatabasesDB, $document) { + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($databaseDoc, $document); + $callback($dbForDatabases); + }; + + batch(array_map( + fn ($databaseDoc) => fn () => $this->cleanDatabase( + $databaseDoc, + $executionActionPerDatabase, + $projectTables, + $projectCollectionIds + ), + $databasesToClean + )); // Delete Platforms $this->deleteByGroup('platforms', [ @@ -629,7 +755,15 @@ class Deletes extends Action // Delete metadata table if ($projectTables) { - $dbForProject->deleteCollection(Database::METADATA); + batch(array_map( + fn ($databaseDoc) => fn () => + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => + $dbForDatabases->deleteCollection(Database::METADATA) + ), + $databasesToClean + )); } elseif ($sharedTablesV1) { $this->deleteByGroup( Database::METADATA, diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 0932aea335..29ccb0ef09 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -3,15 +3,15 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; +use Appwrite\Bus\Events\ExecutionCompleted; use Appwrite\Event\Event; -use Appwrite\Event\Execution as ExecutionEvent; use Appwrite\Event\Func; use Appwrite\Event\Realtime; -use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Utopia\Response\Model\Execution; use Executor\Executor; +use Utopia\Bus\Bus; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; @@ -47,8 +47,7 @@ class Functions extends Action ->inject('queueForFunctions') ->inject('queueForRealtime') ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForExecutions') + ->inject('bus') ->inject('log') ->inject('executor') ->inject('isResourceBlocked') @@ -63,8 +62,7 @@ class Functions extends Action Func $queueForFunctions, Realtime $queueForRealtime, Event $queueForEvents, - StatsUsage $queueForStatsUsage, - ExecutionEvent $queueForExecutions, + Bus $bus, Log $log, Executor $executor, callable $isResourceBlocked @@ -158,9 +156,8 @@ class Functions extends Action queueForWebhooks: $queueForWebhooks, queueForFunctions: $queueForFunctions, queueForRealtime: $queueForRealtime, - queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, - queueForExecutions: $queueForExecutions, + bus: $bus, project: $project, function: $function, executor: $executor, @@ -203,9 +200,8 @@ class Functions extends Action queueForWebhooks: $queueForWebhooks, queueForFunctions: $queueForFunctions, queueForRealtime: $queueForRealtime, - queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, - queueForExecutions: $queueForExecutions, + bus: $bus, project: $project, function: $function, executor: $executor, @@ -230,9 +226,8 @@ class Functions extends Action queueForWebhooks: $queueForWebhooks, queueForFunctions: $queueForFunctions, queueForRealtime: $queueForRealtime, - queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, - queueForExecutions: $queueForExecutions, + bus: $bus, project: $project, function: $function, executor: $executor, @@ -266,7 +261,7 @@ class Functions extends Action private function fail( string $message, Document $project, - ExecutionEvent $queueForExecutions, + Bus $bus, Document $function, string $trigger, string $path, @@ -309,10 +304,10 @@ class Functions extends Action 'duration' => 0.0, ]); - $queueForExecutions - ->setExecution($execution) - ->setProject($project) - ->trigger(); + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + )); } /** @@ -320,7 +315,6 @@ class Functions extends Action * @param Database $dbForProject * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param StatsUsage $queueForStatsUsage * @param Event $queueForEvents * @param Document $project * @param Document $function @@ -343,9 +337,8 @@ class Functions extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - StatsUsage $queueForStatsUsage, Event $queueForEvents, - ExecutionEvent $queueForExecutions, + Bus $bus, Document $project, Document $function, Executor $executor, @@ -364,7 +357,7 @@ class Functions extends Action $user ??= new Document(); $functionId = $function->getId(); $deploymentId = $function->getAttribute('deploymentId', ''); - $spec = Config::getParam('specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + $spec = Config::getParam('specifications')[$function->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; $log->addTag('deploymentId', $deploymentId); @@ -373,19 +366,19 @@ class Functions extends Action if ($deployment->getAttribute('resourceId') !== $functionId) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $bus, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->isEmpty()) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $bus, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->getAttribute('status') !== 'ready') { $errorMessage = 'The execution could not be completed because the build is not ready. Please wait for the build to complete and try again.'; - $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $bus, $function, $trigger, $path, $method, $user, $jwt, $event); return; } @@ -518,6 +511,11 @@ class Functions extends Action try { $version = $function->getAttribute('version', 'v2'); $command = $runtime['startCommand']; + + if (!empty($deployment->getAttribute('startCommand', ''))) { + $command = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + } + $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; @@ -592,26 +590,12 @@ class Functions extends Action $error = $th->getMessage(); $errorCode = $th->getCode(); } finally { - /** Persist final execution status */ - $queueForExecutions - ->setExecution($execution) - ->setProject($project) - ->trigger(); - - /** Trigger usage queue */ - $queueForStatsUsage - ->setProject($project) - ->addMetric(METRIC_EXECUTIONS, 1) - ->addMetric(str_replace(['{resourceType}'], [RESOURCE_TYPE_FUNCTIONS], METRIC_RESOURCE_TYPE_EXECUTIONS), 1) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1) - ->addMetric(METRIC_EXECUTIONS_COMPUTE, (int)($execution->getAttribute('duration') * 1000))// per project - ->addMetric(str_replace(['{resourceType}'], [RESOURCE_TYPE_FUNCTIONS], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) - ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->addMetric(str_replace(['{resourceType}'], [RESOURCE_TYPE_FUNCTIONS], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->trigger() - ; + /** Persist final execution status and record usage */ + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + )); } $executionModel = new Execution(); diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 72f7cddd06..f144c58e1b 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -214,6 +214,7 @@ class Mails extends Action $mail->Password = $password; $mail->SMTPSecure = $smtp['secure']; $mail->SMTPAutoTLS = false; + $mail->SMTPKeepAlive = true; $mail->CharSet = 'UTF-8'; $mail->Timeout = 10; /* Connection timeout */ $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index 3fd15478d5..d866cc2bd0 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -365,7 +365,13 @@ class Messaging extends Action $message->setAttribute('deliveredTotal', $deliveredTotal); $message->setAttribute('deliveredAt', DateTime::now()); - $dbForProject->updateDocument('messages', $message->getId(), $message); + $dbForProject->updateDocument('messages', $message->getId(), new Document([ + 'deliveryErrors' => $message->getAttribute('deliveryErrors'), + 'status' => $message->getAttribute('status'), + 'search' => $message->getAttribute('search'), + 'deliveredTotal' => $message->getAttribute('deliveredTotal'), + 'deliveredAt' => $message->getAttribute('deliveredAt'), + ])); // Delete any attachments that were downloaded to local storage if ($provider->getAttribute('type') === MESSAGE_TYPE_EMAIL) { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 9e4f76957f..9a47530630 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -50,6 +50,18 @@ class Migrations extends Action protected ?Device $deviceForMigrations; protected ?Device $deviceForFiles; protected ?Document $project; + + protected Document $sourceProject; + + /** + * @var callable(Document $databaseDSN): Database + */ + protected mixed $getDatabasesDB; + + /** + * @var callable(Document $databaseDSN): Database + */ + protected mixed $getProjectDB; protected array $plan = []; /** @@ -79,6 +91,8 @@ class Migrations extends Action ->inject('project') ->inject('dbForProject') ->inject('dbForPlatform') + ->inject('getDatabasesDB') + ->inject('getProjectDB') ->inject('logError') ->inject('queueForRealtime') ->inject('deviceForMigrations') @@ -98,6 +112,8 @@ class Migrations extends Action Document $project, Database $dbForProject, Database $dbForPlatform, + callable $getDatabasesDB, + callable $getProjectDB, callable $logError, Realtime $queueForRealtime, Device $deviceForMigrations, @@ -108,6 +124,9 @@ class Migrations extends Action Authorization $authorization, ): void { $payload = $message->getPayload() ?? []; + $this->getDatabasesDB = $getDatabasesDB; + $this->getProjectDB = $getProjectDB; + $this->deviceForMigrations = $deviceForMigrations; $this->deviceForFiles = $deviceForFiles; $this->plan = $plan; @@ -175,13 +194,14 @@ class Migrations extends Action $resourceId = $migration->getAttribute('resourceId'); $credentials = $migration->getAttribute('credentials'); $migrationOptions = $migration->getAttribute('options'); - $dataSource = SourceAppwrite::SOURCE_API; - $database = null; + if ($credentials['projectId']) { + $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } + $getDatabasesDB = fn (Document $database): Database => + $this->getDatabasesDBForProject($database); $queries = []; - if ($source === SourceAppwrite::getName() && $destination === DestinationCSV::getName()) { - $dataSource = SourceAppwrite::SOURCE_DATABASE; - $database = $this->dbForProject; $queries = Query::parseQueries($migrationOptions['queries']); } @@ -211,15 +231,17 @@ class Migrations extends Action $credentials['projectId'], $credentials['endpoint'], $credentials['apiKey'], - $dataSource, - $database, - $queries, + $getDatabasesDB, + SourceAppwrite::SOURCE_DATABASE, + $projectDB, + $queries ), CSV::getName() => new CSV( $resourceId, $migrationOptions['path'], $this->deviceForMigrations, - $this->dbForProject + $this->dbForProject, + $getDatabasesDB ), default => throw new \Exception('Invalid source type'), }; @@ -245,6 +267,7 @@ class Migrations extends Action $credentials['destinationEndpoint'], $credentials['destinationApiKey'], $this->dbForProject, + $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], $this->dbForPlatform, $this->project->getSequence(), @@ -297,6 +320,10 @@ class Migrations extends Action 'disabledMetrics' => [ METRIC_DATABASES_OPERATIONS_READS, METRIC_DATABASES_OPERATIONS_WRITES, + METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB, + METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB, + METRIC_DATABASES_OPERATIONS_READS_VECTORSDB, + METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB, METRIC_NETWORK_REQUESTS, METRIC_NETWORK_INBOUND, METRIC_NETWORK_OUTBOUND, @@ -316,6 +343,16 @@ class Migrations extends Action 'sites.write', 'tokens.read', 'tokens.write', + 'providers.read', + 'providers.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'messages.read', + 'messages.write', + 'targets.read', + 'targets.write', ] ]); @@ -498,11 +535,9 @@ class Migrations extends Action } $destination?->success(); $source?->success(); - - // TODO: Move to CSV hook - if ($migration->getAttribute('destination') === DestinationCSV::getName()) { - $this->handleCSVExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); - } + } + if ($migration->getAttribute('destination') === DestinationCSV::getName()) { + $this->handleCSVExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); } } finally { $source?->cleanup(); @@ -515,6 +550,14 @@ class Migrations extends Action } } + protected function getDatabasesDBForProject(Document $database) + { + if ($this->sourceProject) { + return ($this->getDatabasesDB)($database, $this->sourceProject); + } + return ($this->getDatabasesDB)($database); + } + /** * Handle actions to be performed when a CSV export migration is successfully completed * @@ -570,11 +613,10 @@ class Migrations extends Action } finally { $message = "Export file size {$sizeMB}MB exceeds your plan limit."; - $this->dbForProject->updateDocument('migrations', $migration->getId(), $migration->setAttribute( - 'errors', - json_encode(['code' => 0, 'message' => $message]), - Document::SET_TYPE_APPEND, - )); + $errors = $migration->getAttribute('errors', []); + $errors[] = json_encode(['code' => 0, 'message' => $message]); + $migration->setAttribute('errors', $errors); + $migration = $this->updateMigrationDocument($migration, $project, $queueForRealtime); $this->sendCSVEmail( success: false, diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 858370c421..54545eefb0 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -47,6 +47,7 @@ class StatsResources extends Action ->inject('project') ->inject('getProjectDB') ->inject('getLogsDB') + ->inject('getDatabasesDB') ->inject('dbForPlatform') ->inject('logError') ->callback($this->action(...)); @@ -56,11 +57,13 @@ class StatsResources extends Action * @param Message $message * @param Document $project * @param callable $getProjectDB + * @param callable $getLogsDB + * @param callable $getDatabasesDB * @return void * @throws \Utopia\Database\Exception * @throws Exception */ - public function action(Message $message, Document $project, callable $getProjectDB, callable $getLogsDB, Database $dbForPlatform, callable $logError): void + public function action(Message $message, Document $project, callable $getProjectDB, callable $getLogsDB, callable $getDatabasesDB, Database $dbForPlatform, callable $logError): void { $this->logError = $logError; @@ -76,10 +79,10 @@ class StatsResources extends Action // Reset documents for each job $this->documents = []; - $this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $project); + $this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $getDatabasesDB, $project); } - protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, Document $project): void + protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, callable $getDatabasesDB, Document $project): void { /** @var \Utopia\Database\Database $dbForLogs */ $dbForLogs = call_user_func($getLogsDB, $project); @@ -107,7 +110,9 @@ class StatsResources extends Action ]); - $databases = $dbForProject->count('databases'); + $databases = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB])]); + $documentsdb = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_DOCUMENTSDB])]); + $vectorsdb = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_VECTORSDB])]); $buckets = $dbForProject->count('buckets'); $users = $dbForProject->count('users'); @@ -142,6 +147,8 @@ class StatsResources extends Action $metrics = [ METRIC_DATABASES => $databases, + METRIC_DATABASES_DOCUMENTSDB => $documentsdb, + METRIC_DATABASES_VECTORSDB => $vectorsdb, METRIC_BUCKETS => $buckets, METRIC_USERS => $users, METRIC_FUNCTIONS => $functions, @@ -175,11 +182,11 @@ class StatsResources extends Action try { $this->countImageTransformations($dbForProject, $dbForLogs, $region); } catch (Throwable $th) { - call_user_func_array($this->logError, [$th, "StatsResources", "count_for_buckets_{$project->getId()}"]); + call_user_func_array($this->logError, [$th, "StatsResources", "count_for_image_transformations_{$project->getId()}"]); } try { - $dbForProject->skipFilters(fn () => $this->countForDatabase($dbForProject, $region), ['subQueryAttributes', 'subQueryIndexes']); + $dbForProject->skipFilters(fn () => $this->countForDatabase($dbForProject, $getDatabasesDB, $region), ['subQueryAttributes', 'subQueryIndexes']); } catch (Throwable $th) { call_user_func_array($this->logError, [$th, "StatsResources", "count_for_database_{$project->getId()}"]); } @@ -202,12 +209,23 @@ class StatsResources extends Action $totalFiles = 0; $totalStorage = 0; $this->foreachDocument($dbForProject, 'buckets', [], function ($bucket) use ($dbForProject, $dbForLogs, $region, &$totalFiles, &$totalStorage) { - $files = $dbForProject->count('bucket_' . $bucket->getSequence()); + try { + $files = $dbForProject->count('bucket_' . $bucket->getSequence()); + } catch (Throwable $th) { + call_user_func_array($this->logError, [$th, "StatsResources", "count_for_bucket_{$bucket->getSequence()}"]); + return; + } $metric = str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES); $this->createStatsDocuments($region, $metric, $files); - $storage = $dbForProject->sum('bucket_' . $bucket->getSequence(), 'sizeActual'); + try { + $storage = $dbForProject->sum('bucket_' . $bucket->getSequence(), 'sizeActual'); + } catch (Throwable $th) { + call_user_func_array($this->logError, [$th, "StatsResources", "sum_for_bucket_{$bucket->getSequence()}"]); + return; + } + $metric = str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_STORAGE); $this->createStatsDocuments($region, $metric, $storage); @@ -227,9 +245,15 @@ class StatsResources extends Action $totalImageTransformations = 0; $last30Days = (new \DateTime())->sub(\DateInterval::createFromDateString('30 days'))->format('Y-m-d 00:00:00'); $this->foreachDocument($dbForProject, 'buckets', [], function ($bucket) use ($dbForProject, $last30Days, $region, &$totalImageTransformations) { - $imageTransformations = $dbForProject->count('bucket_' . $bucket->getSequence(), [ - Query::greaterThanEqual('transformedAt', $last30Days), - ]); + try { + $imageTransformations = $dbForProject->count('bucket_' . $bucket->getSequence(), [ + Query::greaterThanEqual('transformedAt', $last30Days), + ]); + } catch (Throwable $th) { + call_user_func_array($this->logError, [$th, "StatsResources", "count_for_image_transformations_bucket_{$bucket->getSequence()}"]); + return; + } + $metric = str_replace('{bucketInternalId}', $bucket->getSequence(), METRIC_BUCKET_ID_FILES_IMAGES_TRANSFORMED); $this->createStatsDocuments($region, $metric, $imageTransformations); $totalImageTransformations += $imageTransformations; @@ -238,51 +262,101 @@ class StatsResources extends Action $this->createStatsDocuments($region, METRIC_FILES_IMAGES_TRANSFORMED, $totalImageTransformations); } - protected function countForDatabase(Database $dbForProject, string $region) + protected function countForDatabase(Database $dbForProject, callable $getDatabasesDB, string $region) { $totalCollections = 0; $totalDocuments = 0; - $totalDatabaseStorage = 0; - $this->foreachDocument($dbForProject, 'databases', [], function ($database) use ($dbForProject, $region, &$totalCollections, &$totalDocuments, &$totalDatabaseStorage) { + // documentsdb + $totalCollectionsDocumentsdb = 0; + $totalDocumentsDocumentsdb = 0; + $totalDatabaseStorageDocumentsdb = 0; + + // vectorsdb + $totalCollectionsVectordb = 0; + $totalDocumentsVectordb = 0; + $totalDatabaseStorageVectordb = 0; + + + $this->foreachDocument($dbForProject, 'databases', [], function ($database) use ($dbForProject, $getDatabasesDB, $region, &$totalCollections, &$totalDocuments, &$totalDatabaseStorage, &$totalCollectionsDocumentsdb, &$totalDocumentsDocumentsdb, &$totalDatabaseStorageDocumentsdb, &$totalCollectionsVectordb, &$totalDocumentsVectordb, &$totalDatabaseStorageVectordb) { + $dbForDatabases = $getDatabasesDB($database); $collections = $dbForProject->count('database_' . $database->getSequence()); - $metric = str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_COLLECTIONS); + $databaseType = $database->getAttribute('type'); + $collectionsMetric = METRIC_DATABASE_ID_COLLECTIONS; + if (!empty($databaseType) && $databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $collectionsMetric = $databaseType . '.' . $collectionsMetric; + } + $metric = str_replace('{databaseInternalId}', $database->getSequence(), $collectionsMetric); $this->createStatsDocuments($region, $metric, $collections); - [$documents, $storage] = $this->countForCollections($dbForProject, $database, $region); + [$documents, $storage] = $this->countForCollections($dbForProject, $dbForDatabases, $database, $region); - $totalDatabaseStorage += $storage; - $totalDocuments += $documents; - $totalCollections += $collections; + switch ($database->getAttribute('type')) { + case DATABASE_TYPE_DOCUMENTSDB: + $totalDatabaseStorageDocumentsdb += $storage; + $totalDocumentsDocumentsdb += $documents; + $totalCollectionsDocumentsdb += $collections; + break; + case DATABASE_TYPE_VECTORSDB: + $totalDatabaseStorageVectordb += $storage; + $totalDocumentsVectordb += $documents; + $totalCollectionsVectordb += $collections; + break; + default: + $totalDatabaseStorage += $storage; + $totalDocuments += $documents; + $totalCollections += $collections; + } }); $this->createStatsDocuments($region, METRIC_COLLECTIONS, $totalCollections); $this->createStatsDocuments($region, METRIC_DOCUMENTS, $totalDocuments); $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE, $totalDatabaseStorage); + + $this->createStatsDocuments($region, METRIC_COLLECTIONS_DOCUMENTSDB, $totalCollectionsDocumentsdb); + $this->createStatsDocuments($region, METRIC_DOCUMENTS_DOCUMENTSDB, $totalDocumentsDocumentsdb); + $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE_DOCUMENTSDB, $totalDatabaseStorageDocumentsdb); + + $this->createStatsDocuments($region, METRIC_COLLECTIONS_VECTORSDB, $totalCollectionsVectordb); + $this->createStatsDocuments($region, METRIC_DOCUMENTS_VECTORSDB, $totalDocumentsVectordb); + $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE_VECTORSDB, $totalDatabaseStorageVectordb); } - protected function countForCollections(Database $dbForProject, Document $database, string $region): array + protected function countForCollections(Database $dbForProject, Database $dbForDatabases, Document $database, string $region): array { $databaseDocuments = 0; $databaseStorage = 0; - $this->foreachDocument($dbForProject, 'database_' . $database->getSequence(), [], function ($collection) use ($dbForProject, $database, $region, &$databaseStorage, &$databaseDocuments) { - $documents = $dbForProject->count('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); - $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); + $databaseType = $database->getAttribute('type'); + $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + $databaseIdCollectionIdStorageMetric = METRIC_DATABASE_ID_COLLECTION_ID_STORAGE; + $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; + $databaseIdStorageMetric = METRIC_DATABASE_ID_STORAGE; + + if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric; + $databaseIdCollectionIdStorageMetric = $databaseType . '.' . $databaseIdCollectionIdStorageMetric; + $databaseIdDocumentsMetric = $databaseType . '.' . $databaseIdDocumentsMetric; + $databaseIdStorageMetric = $databaseType . '.' . $databaseIdStorageMetric; + } + + $this->foreachDocument($dbForProject, 'database_' . $database->getSequence(), [], function ($collection) use ($dbForProject, $dbForDatabases, $database, $region, &$databaseStorage, &$databaseDocuments, $databaseIdCollectionIdDocumentsMetric, $databaseIdCollectionIdStorageMetric) { + $documents = $dbForDatabases->count('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], $databaseIdCollectionIdDocumentsMetric); $this->createStatsDocuments($region, $metric, $documents); $databaseDocuments += $documents; - $collectionStorage = $dbForProject->getSizeOfCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); - $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_STORAGE); + $collectionStorage = $dbForDatabases->getSizeOfCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], $databaseIdCollectionIdStorageMetric); $this->createStatsDocuments($region, $metric, $collectionStorage); $databaseStorage += $collectionStorage; }); - $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], METRIC_DATABASE_ID_DOCUMENTS); + $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], $databaseIdDocumentsMetric); $this->createStatsDocuments($region, $metric, $databaseDocuments); - $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], METRIC_DATABASE_ID_STORAGE); + $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], $databaseIdStorageMetric); $this->createStatsDocuments($region, $metric, $databaseStorage); return [$databaseDocuments, $databaseStorage]; diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 56298a0dcd..a3f810ccab 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -47,6 +47,8 @@ class StatsUsage extends Action */ protected array $skipBaseMetrics = [ METRIC_DATABASES => true, + METRIC_DATABASES_DOCUMENTSDB => true, + METRIC_DATABASES_VECTORSDB => true, METRIC_BUCKETS => true, METRIC_USERS => true, METRIC_FUNCTIONS => true, @@ -66,7 +68,13 @@ class StatsUsage extends Action METRIC_BUILDS => true, METRIC_COLLECTIONS => true, METRIC_DOCUMENTS => true, + METRIC_COLLECTIONS_DOCUMENTSDB => true, + METRIC_DOCUMENTS_DOCUMENTSDB => true, + METRIC_COLLECTIONS_VECTORSDB => true, + METRIC_DOCUMENTS_VECTORSDB => true, METRIC_DATABASES_STORAGE => true, + METRIC_DATABASES_STORAGE_DOCUMENTSDB => true, + METRIC_DATABASES_STORAGE_VECTORSDB => true, ]; /** @@ -85,6 +93,12 @@ class StatsUsage extends Action '.databases.storage' ]; + public const DATABASE_PREFIXES = [ + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB, + DATABASE_TYPE_DOCUMENTSDB, + ]; + /** * @var callable(): Database */ @@ -146,6 +160,11 @@ class StatsUsage extends Action $aggregationInterval = (int) System::getEnv('_APP_USAGE_AGGREGATION_INTERVAL', '20'); $project = new Document($payload['project'] ?? []); $projectId = $project->getSequence(); + + // Get database type from context + $databaseContext = $payload['context']['database'] ?? null; + $databaseType = $databaseContext ? (new Document($databaseContext))->getAttribute('type', '') : ''; + foreach ($payload['reduce'] ?? [] as $document) { if (empty($document)) { continue; @@ -155,12 +174,13 @@ class StatsUsage extends Action project: $project, document: new Document($document), metrics: $payload['metrics'], - getProjectDB: $getProjectDB + getProjectDB: $getProjectDB, + databaseType: $databaseType ); } $this->stats[$projectId]['project'] = $project; - $this->stats[$projectId]['receivedAt'] = DateTime::now(); + $this->stats[$projectId]['receivedAt'] = DateTime::format(new \DateTime('@' . $message->getTimestamp())); foreach ($payload['metrics'] ?? [] as $metric) { $this->keys++; if (!isset($this->stats[$projectId]['keys'][$metric['key']])) { @@ -193,9 +213,10 @@ class StatsUsage extends Action * @param Document $document * @param array $metrics * @param callable(): Database $getProjectDB + * @param string $databaseType Database type from context * @return void */ - protected function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB): void + protected function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB, string $databaseType = ''): void { $dbForProject = $getProjectDB($project); @@ -211,38 +232,48 @@ class StatsUsage extends Action } break; case $document->getCollection() === 'databases': // databases - $collections = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), METRIC_DATABASE_ID_COLLECTIONS))); - $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), METRIC_DATABASE_ID_DOCUMENTS))); + $databaseCollectionsMetric = implode('.', array_filter([$databaseType,METRIC_COLLECTIONS])); + $databaseDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DOCUMENTS])); + + $databaseIdCollectionsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_COLLECTIONS])); + $databaseIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_DOCUMENTS])); + + $collections = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), $databaseIdCollectionsMetric))); + $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), $databaseIdDocumentsMetric))); if (!empty($collections['value'])) { $metrics[] = [ - 'key' => METRIC_COLLECTIONS, + 'key' => $databaseCollectionsMetric, 'value' => ($collections['value'] * -1), ]; } if (!empty($documents['value'])) { $metrics[] = [ - 'key' => METRIC_DOCUMENTS, + 'key' => $databaseDocumentsMetric, 'value' => ($documents['value'] * -1), ]; } break; case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections + $databaseDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DOCUMENTS])); + $databaseIdCollectionIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS])); + $databaseIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_DOCUMENTS])); + $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace( ['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $document->getSequence()], - METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS + $databaseIdCollectionIdDocumentsMetric ))); if (!empty($documents['value'])) { $metrics[] = [ - 'key' => METRIC_DOCUMENTS, + 'key' => $databaseDocumentsMetric, 'value' => ($documents['value'] * -1), ]; $metrics[] = [ - 'key' => str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), + 'key' => str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), 'value' => ($documents['value'] * -1), ]; } @@ -473,8 +504,11 @@ class StatsUsage extends Action if (array_key_exists($stat->getAttribute('metric'), $this->skipBaseMetrics)) { return; } + foreach ($this->skipParentIdMetrics as $skipMetric) { - if (str_ends_with($stat->getAttribute('metric'), $skipMetric)) { + $metricParts = explode('.', $stat->getAttribute('metric')); + $metric = implode('.', in_array($metricParts[0], self::DATABASE_PREFIXES) ? array_slice($metricParts, 1) : $metricParts); + if (str_ends_with($metric, $skipMetric)) { return; } } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 56839058de..4855a1d4d8 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -168,12 +168,15 @@ class Webhooks extends Action $webhook->setAttribute('logs', $logs); + $updatePayload = ['logs' => $logs]; + if ($attempts >= \intval(System::getEnv('_APP_WEBHOOK_MAX_FAILED_ATTEMPTS', '10'))) { $webhook->setAttribute('enabled', false); + $updatePayload['enabled'] = false; $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForPlatform, $queueForMails, $plan); } - $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $webhook); + $dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document($updatePayload)); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); $this->errors[] = $logs; @@ -184,8 +187,9 @@ class Webhooks extends Action } else { - $webhook->setAttribute('attempts', 0); // Reset attempts on success - $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $webhook); + $dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document([ + 'attempts' => 0, + ])); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); $queueForStatsUsage ->addMetric(METRIC_WEBHOOKS_SENT, 1) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index bd2f063073..04ecafa8fc 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -309,7 +309,7 @@ abstract class Format case 'createIndex': switch ($param) { case 'type': - return 'IndexType'; + return 'DatabasesIndexType'; case 'orders': return 'OrderBy'; } @@ -342,7 +342,45 @@ abstract class Format case 'createIndex': switch ($param) { case 'type': - return 'IndexType'; + return 'TablesDBIndexType'; + case 'orders': + return 'OrderBy'; + } + } + break; + case 'documentsDB': + switch ($method) { + case 'getUsage': + case 'listUsage': + case 'getCollectionUsage': + switch ($param) { + case 'range': + return 'UsageRange'; + } + break; + case 'createIndex': + switch ($param) { + case 'type': + return 'DocumentsDBIndexType'; + case 'orders': + return 'OrderBy'; + } + } + break; + case 'vectorsDB': + switch ($method) { + case 'getUsage': + case 'listUsage': + case 'getCollectionUsage': + switch ($param) { + case 'range': + return 'UsageRange'; + } + break; + case 'createIndex': + switch ($param) { + case 'type': + return 'VectorsDBIndexType'; case 'orders': return 'OrderBy'; } @@ -630,6 +668,8 @@ abstract class Format } break; case 'databases': + case 'documentsDB': + case 'vectorsDB': switch ($method) { case 'getUsage': case 'listUsage': diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index e9bd009217..acb04bd97e 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -45,10 +45,12 @@ class Attributes extends Validator /** * @param int $maxAttributes Maximum number of attributes allowed * @param bool $supportForSpatialAttributes Whether DB supports spatial attributes + * @param bool $supportForAttributes Whether DB supports attributes or not */ public function __construct( int $maxAttributes = APP_LIMIT_ARRAY_PARAMS_SIZE, protected bool $supportForSpatialAttributes = true, + protected bool $supportForAttributes = true ) { $this->maxAttributes = $maxAttributes; } @@ -78,6 +80,11 @@ class Attributes extends Validator return false; } + if (\count($value) && !$this->supportForAttributes) { + $this->message = 'Attributes are not supported by the current database'; + return false; + } + if (\count($value) > $this->maxAttributes) { $this->message = 'Maximum of ' . $this->maxAttributes . ' attributes allowed'; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Operation.php b/src/Appwrite/Utopia/Database/Validator/Operation.php index e6884ac677..6d50611708 100644 --- a/src/Appwrite/Utopia/Database/Validator/Operation.php +++ b/src/Appwrite/Utopia/Database/Validator/Operation.php @@ -59,6 +59,7 @@ class Operation extends Validator { switch ($this->type) { case 'legacy': + case 'documentsdb': $this->collectionIdName = 'collectionId'; $this->documentIdName = 'documentId'; break; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php index 02f2a57c5b..9d9bbde00b 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php @@ -87,8 +87,6 @@ class Base extends Queries $allAttributes[] = $attribute; } - - $validators = [ new Limit(), new Offset(), diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 3ef0becf1d..4feb8e1926 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -6,7 +6,7 @@ use Appwrite\Utopia\Request\Filter; class V21 extends Filter { - // Convert 1.8.0 params to 1.8.1 + // Convert 1.8.0 params to 1.9.0 public function parse(array $content, string $model): array { switch ($model) { @@ -14,6 +14,12 @@ class V21 extends Filter case 'sites.createTemplateDeployment': $content = $this->convertVersionToTypeAndReference($content); break; + case 'functions.create': + case 'sites.create': + case 'functions.update': + case 'sites.update': + $content = $this->convertSpecs($content); + break; } return $content; } @@ -31,4 +37,15 @@ class V21 extends Filter } return $content; } + + protected function convertSpecs(array $content): array + { + if (!empty($content['specification'])) { + $content['buildSpecification'] = $content['specification']; + $content['runtimeSpecification'] = $content['specification']; + unset($content['specification']); + } + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 1dd2a464eb..18bcea65d8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -32,6 +32,10 @@ class Response extends SwooleResponse public const MODEL_BASE_LIST = 'baseList'; public const MODEL_USAGE_DATABASES = 'usageDatabases'; public const MODEL_USAGE_DATABASE = 'usageDatabase'; + public const MODEL_USAGE_DOCUMENTSDBS = 'usageDocumentsDBs'; + public const MODEL_USAGE_DOCUMENTSDB = 'usageDocumentsDB'; + public const MODEL_USAGE_VECTORSDBS = 'usageVectorsDBs'; + public const MODEL_USAGE_VECTORSDB = 'usageVectorsDB'; public const MODEL_USAGE_TABLE = 'usageTable'; public const MODEL_USAGE_COLLECTION = 'usageCollection'; public const MODEL_USAGE_USERS = 'usageUsers'; @@ -48,6 +52,10 @@ class Response extends SwooleResponse public const MODEL_DATABASE_LIST = 'databaseList'; public const MODEL_COLLECTION = 'collection'; public const MODEL_COLLECTION_LIST = 'collectionList'; + public const MODEL_VECTORSDB_COLLECTION = 'vectorsdbCollection'; + public const MODEL_VECTORSDB_COLLECTION_LIST = 'vectorsdbCollectionList'; + public const MODEL_EMBEDDING = 'embedding'; + public const MODEL_EMBEDDING_LIST = 'embeddingList'; public const MODEL_TABLE = 'table'; public const MODEL_TABLE_LIST = 'tableList'; public const MODEL_INDEX = 'index'; @@ -79,6 +87,8 @@ class Response extends SwooleResponse public const MODEL_ATTRIBUTE_TEXT = 'attributeText'; public const MODEL_ATTRIBUTE_MEDIUMTEXT = 'attributeMediumtext'; public const MODEL_ATTRIBUTE_LONGTEXT = 'attributeLongtext'; + public const MODEL_ATTRIBUTE_OBJECT = 'attributeObject'; + public const MODEL_ATTRIBUTE_VECTOR = 'attributeVector'; // Database Columns public const MODEL_COLUMN = 'column'; diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php new file mode 100644 index 0000000000..4a414a9418 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -0,0 +1,51 @@ + $this->parseSite($content), + Response::MODEL_SITE_LIST => $this->handleList( + $content, + "sites", + fn ($item) => $this->parseSite($item), + ), + Response::MODEL_FUNCTION => $this->parseFunction($content), + Response::MODEL_FUNCTION_LIST => $this->handleList( + $content, + "functions", + fn ($item) => $this->parseFunction($item), + ), + default => $parsedResponse, + }; + } + + protected function parseSite(array $content): array + { + $content = $this->parseSpecs($content); + return $content; + } + + protected function parseFunction(array $content): array + { + $content = $this->parseSpecs($content); + return $content; + } + + protected function parseSpecs(array $content): array + { + $content['specification'] = $content['buildSpecification'] ?? $content['specification'] ?? null; + unset($content['buildSpecification']); + unset($content['runtimeSpecification']); + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/AttributeObject.php b/src/Appwrite/Utopia/Response/Model/AttributeObject.php new file mode 100644 index 0000000000..542f7f744c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/AttributeObject.php @@ -0,0 +1,27 @@ + 'object', + ]; + + public function getName(): string + { + return 'AttributeObject'; + } + + public function getType(): string + { + return Response::MODEL_ATTRIBUTE_OBJECT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/AttributeVector.php b/src/Appwrite/Utopia/Response/Model/AttributeVector.php new file mode 100644 index 0000000000..4b58b979ee --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/AttributeVector.php @@ -0,0 +1,35 @@ +addRule('size', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Vector dimensions.', + 'default' => 0, + 'example' => 1536, + ]); + } + + public array $conditions = [ + 'type' => 'vector', + ]; + + public function getName(): string + { + return 'AttributeVector'; + } + + public function getType(): string + { + return Response::MODEL_ATTRIBUTE_VECTOR; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Database.php b/src/Appwrite/Utopia/Response/Model/Database.php index 59f32b3162..9884669d1b 100644 --- a/src/Appwrite/Utopia/Response/Model/Database.php +++ b/src/Appwrite/Utopia/Response/Model/Database.php @@ -45,9 +45,8 @@ class Database extends Model 'description' => 'Database type.', 'default' => 'legacy', 'example' => 'legacy', - 'enum' => ['legacy', 'tablesdb'], - ]) - ; + 'enum' => ['legacy', 'tablesdb', 'documentsdb'], + ]); } /** diff --git a/src/Appwrite/Utopia/Response/Model/Embedding.php b/src/Appwrite/Utopia/Response/Model/Embedding.php new file mode 100644 index 0000000000..9fce913723 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Embedding.php @@ -0,0 +1,47 @@ +addRule('model', [ + 'type' => self::TYPE_STRING, + 'description' => 'Embedding model used to generate embeddings.', + 'example' => 'embeddinggemma' + ]) + ->addRule('dimension', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of dimensions for each embedding vector.', + 'example' => 768 + ]) + ->addRule('embedding', [ + 'type' => self::TYPE_FLOAT, + 'array' => true, + 'default' => [], + 'description' => 'Embedding vector values. If an error occurs, this will be an empty array.', + 'example' => [0.01, 0.02, 0.03] + ]) + ->addRule('error', [ + 'type' => self::TYPE_STRING, + 'array' => false, + 'default' => '', + 'description' => 'Error message if embedding generation fails. Empty string if no error.', + 'example' => 'Error message' + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index e33d7663fd..3aea364fe5 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -65,6 +65,12 @@ class Func extends Model 'default' => '', 'example' => 'python-3.8', ]) + ->addRule('deploymentRetention', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'How many days to keep the non-active deployments before they will be automatically deleted.', + 'default' => 0, + 'example' => 7, + ]) ->addRule('deploymentId', [ 'type' => self::TYPE_STRING, 'description' => 'Function\'s active deployment ID.', @@ -176,9 +182,15 @@ class Func extends Model 'default' => false, 'example' => false, ]) - ->addRule('specification', [ + ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, - 'description' => 'Machine specification for builds and executions.', + 'description' => 'Machine specification for deployment builds.', + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'example' => APP_COMPUTE_SPECIFICATION_DEFAULT, + ]) + ->addRule('runtimeSpecification', [ + 'type' => self::TYPE_STRING, + 'description' => 'Machine specification for executions.', 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, 'example' => APP_COMPUTE_SPECIFICATION_DEFAULT, ]) diff --git a/src/Appwrite/Utopia/Response/Model/MigrationReport.php b/src/Appwrite/Utopia/Response/Model/MigrationReport.php index 36d77044bd..388630af3f 100644 --- a/src/Appwrite/Utopia/Response/Model/MigrationReport.php +++ b/src/Appwrite/Utopia/Response/Model/MigrationReport.php @@ -65,6 +65,30 @@ class MigrationReport extends Model 'default' => 0, 'example' => 5, ]) + ->addRule(Resource::TYPE_PROVIDER, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of providers to be migrated.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule(Resource::TYPE_TOPIC, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of topics to be migrated.', + 'default' => 0, + 'example' => 10, + ]) + ->addRule(Resource::TYPE_SUBSCRIBER, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of subscribers to be migrated.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule(Resource::TYPE_MESSAGE, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of messages to be migrated.', + 'default' => 0, + 'example' => 50, + ]) ->addRule('size', [ 'type' => self::TYPE_INTEGER, 'description' => 'Size of files to be migrated in mb.', diff --git a/src/Appwrite/Utopia/Response/Model/Site.php b/src/Appwrite/Utopia/Response/Model/Site.php index e6e205909b..941b6104df 100644 --- a/src/Appwrite/Utopia/Response/Model/Site.php +++ b/src/Appwrite/Utopia/Response/Model/Site.php @@ -58,6 +58,12 @@ class Site extends Model 'default' => '', 'example' => 'react', ]) + ->addRule('deploymentRetention', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'How many days to keep the non-active deployments before they will be automatically deleted.', + 'default' => 0, + 'example' => 7, + ]) ->addRule('deploymentId', [ 'type' => self::TYPE_STRING, 'description' => 'Site\'s active deployment ID.', @@ -125,6 +131,12 @@ class Site extends Model 'default' => '', 'example' => 'npm run build', ]) + ->addRule('startCommand', [ + 'type' => self::TYPE_STRING, + 'description' => 'Custom command to use when starting site runtime.', + 'default' => '', + 'example' => 'node custom-server.mjs', + ]) ->addRule('outputDirectory', [ 'type' => self::TYPE_STRING, 'description' => 'The directory where the site build output is located.', @@ -161,9 +173,15 @@ class Site extends Model 'default' => false, 'example' => false, ]) - ->addRule('specification', [ + ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, - 'description' => 'Machine specification for builds and executions.', + 'description' => 'Machine specification for deployment builds.', + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'example' => APP_COMPUTE_SPECIFICATION_DEFAULT, + ]) + ->addRule('runtimeSpecification', [ + 'type' => self::TYPE_STRING, + 'description' => 'Machine specification for SSR executions.', 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, 'example' => APP_COMPUTE_SPECIFICATION_DEFAULT, ]) diff --git a/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php new file mode 100644 index 0000000000..099a9887b8 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php @@ -0,0 +1,96 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage used in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage used in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageDocumentsDB'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_DOCUMENTSDB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php new file mode 100644 index 0000000000..5ce229ce4a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php @@ -0,0 +1,109 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('databasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of DocumentsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of total databases storage in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of databases reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of databases writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of the aggregated number of databases storage in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageDocumentsDBs'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_DOCUMENTSDBS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageProject.php b/src/Appwrite/Utopia/Response/Model/UsageProject.php index ee644aa845..e00c4bc1dc 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageProject.php +++ b/src/Appwrite/Utopia/Response/Model/UsageProject.php @@ -18,7 +18,13 @@ class UsageProject extends Model ]) ->addRule('documentsTotal', [ 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of documents.', + 'description' => 'Total aggregated number of documents in legacy/tablesdb.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsdbDocumentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents in documentsdb.', 'default' => 0, 'example' => 0, ]) @@ -34,12 +40,24 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documentsdb.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('databasesStorageTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated sum of databases storage size (in bytes).', 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbDatabasesStorageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated sum of documentsdb databases storage size (in bytes).', + 'default' => 0, + 'example' => 0, + ]) ->addRule('usersTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of users.', @@ -100,6 +118,18 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbDatabasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of documentsdb databases reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsdbDatabasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of documentsdb databases writes.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('requests', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of requests per period.', @@ -203,6 +233,27 @@ class UsageProject extends Model 'example' => [], 'array' => true ]) + ->addRule('documentsdbDatabasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of documentsdb database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documentsdbDatabasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of documentsdb database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documentsdbDatabasesStorage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated sum of documentsdb databases storage size (in bytes) per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('imageTransformations', [ 'type' => Response::MODEL_METRIC, 'description' => 'An array of aggregated number of image transformations.', @@ -216,6 +267,133 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + // VectorsDB aggregates + ->addRule('vectorsdbDatabasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbCollectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDocumentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesStorageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated VectorsDB storage (bytes).', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbCollections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDocuments', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesStorage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB storage per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB reads per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB writes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('embeddingsText', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of text embedding calls per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextTokens', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of tokens processed by text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextDuration', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated duration spent generating text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextErrors', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of errors while generating text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of text embedding calls.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextTokensTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of tokens processed by text.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextDurationTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated duration spent generating text embeddings.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextErrorsTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of errors while generating text embeddings.', + 'default' => 0, + 'example' => 0 + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php b/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php new file mode 100644 index 0000000000..c652a3d62e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php @@ -0,0 +1,96 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage used in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage used in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageVectorsDB'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_VECTORSDB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php b/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php new file mode 100644 index 0000000000..1f5fe7853d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php @@ -0,0 +1,109 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('databasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageVectorsDBs'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_VECTORSDBS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php b/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php new file mode 100644 index 0000000000..5053628e74 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php @@ -0,0 +1,41 @@ +addRule('dimension', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Embedding dimension.', + 'default' => 0, + 'example' => 1536, + ]) + ->addRule('attributes', [ + 'type' => [ + Response::MODEL_ATTRIBUTE_OBJECT, + Response::MODEL_ATTRIBUTE_VECTOR, + ], + 'description' => 'Collection attributes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'VectorsDB Collection'; + } + + public function getType(): string + { + return Response::MODEL_VECTORSDB_COLLECTION; + } +} diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 90f9c8f95d..e6d6996748 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -251,7 +251,7 @@ class Comment $json = \base64_decode($state); $builds = \json_decode($json, true); - $this->builds = $builds; + $this->builds = \is_array($builds) ? $builds : []; return $this; } diff --git a/src/Utopia/Bus/Bus.php b/src/Utopia/Bus/Bus.php new file mode 100644 index 0000000000..bef39f0481 --- /dev/null +++ b/src/Utopia/Bus/Bus.php @@ -0,0 +1,51 @@ +, Listener[]> */ + private array $listeners = []; + + /** @var ?\Closure(string): mixed */ + private ?\Closure $resolver = null; + + public function setResolver(callable $resolver): self + { + $this->resolver = $resolver(...); + return $this; + } + + public function subscribe(Listener $listener): self + { + foreach ($listener::getEvents() as $event) { + $this->listeners[$event][] = $listener; + } + return $this; + } + + public function dispatch(Event $event): void + { + if ($this->resolver === null) { + throw new \LogicException('Bus resolver must be set via setResolver() before dispatching events'); + } + + $resolver = $this->resolver; + $listeners = $this->listeners[$event::class] ?? []; + + foreach ($listeners as $listener) { + $deps = array_map($resolver, $listener->getInjections()); + Span::init('listener.' . $listener::getName()); + Span::add('bus.event', $event::class); + try { + ($listener->getCallback())($event, ...$deps); + } catch (\Throwable $e) { + Span::error($e); + } finally { + Span::current()?->finish(); + } + } + } +} diff --git a/src/Utopia/Bus/Event.php b/src/Utopia/Bus/Event.php new file mode 100644 index 0000000000..1423f1198d --- /dev/null +++ b/src/Utopia/Bus/Event.php @@ -0,0 +1,7 @@ + */ + protected array $injections = []; + protected ?\Closure $callback = null; + + abstract public static function getName(): string; + + /** + * @return array> + */ + abstract public static function getEvents(): array; + + protected function desc(string $desc): self + { + $this->desc = $desc; + return $this; + } + + protected function inject(string $injection): self + { + $this->injections[] = $injection; + return $this; + } + + protected function callback(callable $callback): self + { + $this->callback = $callback(...); + return $this; + } + + /** @return array */ + public function getInjections(): array + { + return $this->injections; + } + + public function getCallback(): callable + { + if ($this->callback === null) { + throw new \LogicException(static::class . ' must set a callback via $this->callback()'); + } + + return $this->callback; + } +} diff --git a/tests/e2e/General/HooksTest.php b/tests/e2e/General/HooksTest.php index 1e7d87608f..812fb61496 100644 --- a/tests/e2e/General/HooksTest.php +++ b/tests/e2e/General/HooksTest.php @@ -128,7 +128,7 @@ class HooksTest extends Scope 'cookie' => $cookie, ]); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); /** * Test for api controllers @@ -140,7 +140,7 @@ class HooksTest extends Scope 'cookie' => $cookie, ]); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); $this->assertEquals(Exception::USER_BLOCKED, $response['body']['type']); /** diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index e7b5b1d844..16346f95bd 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -3,6 +3,7 @@ namespace Tests\E2E\General; use Appwrite\Platform\Modules\Compute\Specification; +use Appwrite\Tests\Retry; use CURLFile; use DateTime; use PHPUnit\Framework\Attributes\Depends; @@ -602,6 +603,8 @@ class UsageTest extends Scope $collectionsTotal = $data['collectionsTotal']; $documentsTotal = $data['documentsTotal']; + sleep(self::WAIT); + $this->assertEventually(function () use ($requestsTotal, $databasesTotal, $documentsTotal) { $response = $this->client->call( Client::METHOD_GET, @@ -929,6 +932,446 @@ class UsageTest extends Scope } #[Depends('testDatabaseStatsTablesAPI')] + public function testPrepareDocumentsDBStats(array $data): array + { + $documentsTotal = 0; + $collectionsTotal = 0; + $documentsDbTotal = 0; + $databasesTotal = $data['databasesTotal']; + $requestsTotal = $data['requestsTotal']; + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' documentsdb'; + + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'databaseId' => 'unique()', + 'name' => $name, + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsDbTotal += 1; + + $documentsDbId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsDbTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' collection'; + + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb/' . $documentsDbId . '/collections', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'collectionId' => 'unique()', + 'name' => $name, + 'documentSecurity' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $collectionsTotal += 1; + + $collectionId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $collectionsTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/documents', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'documentId' => 'unique()', + 'data' => [ + 'name' => uniqid() . ' document', + 'value' => $i + ] + ] + ); + + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsTotal += 1; + + $documentId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/documents/' . $documentId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsTotal -= 1; + $requestsTotal += 1; + } + } + + return array_merge($data, [ + 'documentsDbId' => $documentsDbId, + 'documentsDbCollectionId' => $collectionId, + 'requestsTotal' => $requestsTotal, + 'databasesTotal' => $databasesTotal, + 'documentsDbTotal' => $documentsDbTotal, + 'documentsDbCollectionsTotal' => $collectionsTotal, + 'documentsDbDocumentsTotal' => $documentsTotal, + ]); + } + + #[Depends('testPrepareDocumentsDBStats')] + #[Retry(count: 1)] + public function testDocumentsDBStats(array $data): array + { + $documentsDbId = $data['documentsDbId']; + $collectionId = $data['documentsDbCollectionId']; + $requestsTotal = $data['requestsTotal']; + $databasesTotal = $data['databasesTotal']; + $documentsDbTotal = $data['documentsDbTotal']; + $collectionsTotal = $data['documentsDbCollectionsTotal']; + $documentsTotal = $data['documentsDbDocumentsTotal']; + + sleep(self::WAIT); + + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertGreaterThanOrEqual(31, count($response['body'])); + $this->assertCount(1, $response['body']['requests']); + $this->assertCount(1, $response['body']['network']); + $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); + $this->validateDates($response['body']['requests']); + // documentsdbTotal should reflect only documents DB instances, not relational databases. + $this->assertEquals($documentsDbTotal, $response['body']['documentsdbTotal']); + $this->assertEquals($documentsTotal, $response['body']['documentsdbDocumentsTotal']); + + $response = $this->client->call( + Client::METHOD_GET, + '/databases/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); + $this->validateDates($response['body']['databases']); + + $this->assertEventually(function () use ($documentsDbId, $collectionsTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/documentsdb/' . $documentsDbId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($collectionsTotal, $response['body']['collections'][array_key_last($response['body']['collections'])]['value']); + $this->validateDates($response['body']['collections']); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + $this->assertEventually(function () use ($documentsDbId, $collectionId, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + return $data; + } + + #[Depends('testDocumentsDBStats')] + public function testPrepareVectorsDBStats(array $data): array + { + $documentsTotal = 0; + $collectionsTotal = 0; + $vectordbTotal = 0; + $databasesTotal = $data['databasesTotal']; + $requestsTotal = $data['requestsTotal']; + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' vectorsdb'; + + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'databaseId' => 'unique()', + 'name' => $name, + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $vectordbTotal += 1; + + $vectordbId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $vectordbTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' collection'; + + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/' . $vectordbId . '/collections', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'collectionId' => 'unique()', + 'name' => $name, + 'dimension' => 1536, + 'documentSecurity' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $collectionsTotal += 1; + + $collectionId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $collectionsTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/documents', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'documentId' => 'unique()', + 'data' => [ + 'embeddings' => array_fill(0, 1536, 0.1), + 'metadata' => [ + 'name' => uniqid() . ' document', + 'value' => $i + ] + ] + ] + ); + + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsTotal += 1; + + $documentId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/documents/' . $documentId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsTotal -= 1; + $requestsTotal += 1; + } + } + + return array_merge($data, [ + 'vectordbId' => $vectordbId, + 'vectordbCollectionId' => $collectionId, + 'requestsTotal' => $requestsTotal, + 'databasesTotal' => $databasesTotal, + 'vectordbTotal' => $vectordbTotal, + 'vectordbCollectionsTotal' => $collectionsTotal, + 'vectordbDocumentsTotal' => $documentsTotal, + ]); + } + + #[Depends('testPrepareVectorsDBStats')] + #[Retry(count: 1)] + public function testVectorsDBStats(array $data): array + { + $vectordbId = $data['vectordbId']; + $collectionId = $data['vectordbCollectionId']; + $requestsTotal = $data['requestsTotal']; + $databasesTotal = $data['databasesTotal']; + $vectordbTotal = $data['vectordbTotal']; + $collectionsTotal = $data['vectordbCollectionsTotal']; + $documentsTotal = $data['vectordbDocumentsTotal']; + + $this->assertEventually(function () use ($requestsTotal, $vectordbTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertGreaterThanOrEqual(31, count($response['body'])); + $this->assertCount(1, $response['body']['requests']); + $this->assertCount(1, $response['body']['network']); + $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); + $this->validateDates($response['body']['requests']); + // vectordbTotal should reflect only VectorsDB instances, not relational databases. + $this->assertEquals($vectordbTotal, $response['body']['vectordbDatabasesTotal']); + $this->assertEquals($documentsTotal, $response['body']['vectordbDocumentsTotal']); + }); + + $response = $this->client->call( + Client::METHOD_GET, + '/databases/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); + $this->validateDates($response['body']['databases']); + + $this->assertEventually(function () use ($vectordbId, $collectionsTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/vectorsdb/' . $vectordbId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($collectionsTotal, $response['body']['collections'][array_key_last($response['body']['collections'])]['value']); + $this->validateDates($response['body']['collections']); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + $this->assertEventually(function () use ($vectordbId, $collectionId, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + return $data; + } + + #[Depends('testVectorsDBStats')] public function testPrepareFunctionsStats(array $data): array { $executionTime = 0; @@ -958,7 +1401,8 @@ class UsageTest extends Scope ], 'schedule' => '0 0 1 1 *', 'timeout' => 10, - 'specification' => Specification::S_8VCPU_8GB + 'buildSpecification' => Specification::S_8VCPU_8GB, + 'runtimeSpecification' => Specification::S_4VCPU_4GB, ] ); @@ -1408,6 +1852,75 @@ class UsageTest extends Scope }); } + public function testEmbeddingsTextUsageDoesNotBreakProjectUsage(): void + { + // Trigger embeddings endpoint a few times so stats usage worker has data to aggregate + for ($i = 0; $i < 3; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/embeddings/text', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), + [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'usage test text ' . $i, + ], + ] + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['embeddings']); + $this->assertGreaterThan(0, $response['body']['total']); + } + + // Ensure project usage endpoint still responds correctly after embeddings calls + $this->assertEventually(function () { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1h', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('requests', $response['body']); + $this->assertArrayHasKey('network', $response['body']); + $this->assertArrayHasKey('executionsTotal', $response['body']); + + // New embeddings metrics should be present after calls above + $this->assertArrayHasKey('embeddingsText', $response['body']); + $this->assertArrayHasKey('embeddingsTextErrors', $response['body']); + $this->assertArrayHasKey('embeddingsTextTokens', $response['body']); + $this->assertArrayHasKey('embeddingsTextDuration', $response['body']); + $this->assertArrayHasKey('embeddingsTextTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextErrorsTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextTokensTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextDurationTotal', $response['body']); + + // Time-series arrays should be non-empty + $this->assertNotEmpty($response['body']['embeddingsText']); + $this->assertNotEmpty($response['body']['embeddingsTextTokens']); + $this->assertNotEmpty($response['body']['embeddingsTextDuration']); + $this->validateDates($response['body']['embeddingsText']); + $this->validateDates($response['body']['embeddingsTextTokens']); + $this->validateDates($response['body']['embeddingsTextDuration']); + + // Total scalars should be greater than 0 (or >= 0 for errors) + $this->assertGreaterThan(0, $response['body']['embeddingsTextTotal']); + $this->assertGreaterThanOrEqual(0, $response['body']['embeddingsTextErrorsTotal']); + $this->assertGreaterThan(0, $response['body']['embeddingsTextTokensTotal']); + $this->assertGreaterThan(0, $response['body']['embeddingsTextDurationTotal']); + }); + } + public function tearDown(): void { $this->projectId = ''; diff --git a/tests/e2e/Scopes/ApiDocumentsDB.php b/tests/e2e/Scopes/ApiDocumentsDB.php new file mode 100644 index 0000000000..9948b03971 --- /dev/null +++ b/tests/e2e/Scopes/ApiDocumentsDB.php @@ -0,0 +1,109 @@ +assertEquals(201, $project['headers']['status-code'], 'Project creation failed with status: ' . $project['headers']['status-code']); $this->assertNotEmpty($project['body']); - $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ], [ - 'keyId' => ID::unique(), - 'name' => 'Demo Project Key', - 'scopes' => [ - 'users.read', - 'users.write', - 'teams.read', - 'teams.write', - 'databases.read', - 'databases.write', - 'collections.read', - 'collections.write', - 'tables.read', - 'tables.write', - 'documents.read', - 'documents.write', - 'rows.read', - 'rows.write', - 'files.read', - 'files.write', - 'buckets.read', - 'buckets.write', - 'sites.read', - 'sites.write', - 'functions.read', - 'functions.write', - 'sites.read', - 'sites.write', - 'execution.read', - 'execution.write', - 'log.read', - 'log.write', - 'locale.read', - 'avatars.read', - 'health.read', - 'rules.read', - 'rules.write', - 'sessions.write', - 'targets.read', - 'targets.write', - 'providers.read', - 'providers.write', - 'messages.read', - 'messages.write', - 'topics.write', - 'topics.read', - 'subscribers.write', - 'subscribers.read', - 'migrations.write', - 'migrations.read', - 'tokens.read', - 'tokens.write', - ], - ]); + $key = null; + for ($i = 0; $i < $maxRetries; $i++) { + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'keyId' => ID::unique(), + 'name' => 'Demo Project Key', + 'scopes' => [ + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'tables.read', + 'tables.write', + 'documents.read', + 'documents.write', + 'rows.read', + 'rows.write', + 'files.read', + 'files.write', + 'buckets.read', + 'buckets.write', + 'sites.read', + 'sites.write', + 'functions.read', + 'functions.write', + 'sites.read', + 'sites.write', + 'execution.read', + 'execution.write', + 'log.read', + 'log.write', + 'locale.read', + 'avatars.read', + 'health.read', + 'rules.read', + 'rules.write', + 'sessions.write', + 'targets.read', + 'targets.write', + 'providers.read', + 'providers.write', + 'messages.read', + 'messages.write', + 'topics.write', + 'topics.read', + 'subscribers.write', + 'subscribers.read', + 'migrations.write', + 'migrations.read', + 'tokens.read', + 'tokens.write', + ], + ]); - $this->assertEquals(201, $key['headers']['status-code']); + if ($key['headers']['status-code'] === 201) { + break; + } + + if ($key['headers']['status-code'] === 401 && $i < $maxRetries - 1) { + \usleep(500000); + continue; + } + } + + $this->assertEquals(201, $key['headers']['status-code'], 'Key creation failed with status: ' . $key['headers']['status-code']); $this->assertNotEmpty($key['body']); $this->assertNotEmpty($key['body']['secret']); diff --git a/tests/e2e/Scopes/SchemaPolling.php b/tests/e2e/Scopes/SchemaPolling.php index 8296147c12..867ff3213b 100644 --- a/tests/e2e/Scopes/SchemaPolling.php +++ b/tests/e2e/Scopes/SchemaPolling.php @@ -22,6 +22,9 @@ trait SchemaPolling */ protected function waitForAttribute(string $databaseId, string $containerId, string $attributeKey, int $timeoutMs = 240000, int $waitMs = 500): void { + if (!$this->getSupportForAttributes()) { + return; + } $this->assertEventually(function () use ($databaseId, $containerId, $attributeKey) { $attribute = $this->client->call( Client::METHOD_GET, diff --git a/tests/e2e/Scopes/Scope.php b/tests/e2e/Scopes/Scope.php index b0203cbf40..8c62c0c14a 100644 --- a/tests/e2e/Scopes/Scope.php +++ b/tests/e2e/Scopes/Scope.php @@ -59,12 +59,21 @@ abstract class Scope extends TestCase $root = $this->getRoot(); - $response = $this->client->call(Client::METHOD_GET, '/console/variables', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - 'cookie' => 'a_session_console=' . $root['session'], - ]); + for ($i = 0; $i < 3; $i++) { + $response = $this->client->call(Client::METHOD_GET, '/console/variables', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $root['session'], + ]); + + if ($response['headers']['status-code'] === 200 && !empty($response['body'])) { + self::$consoleVariables = $response['body']; + return self::$consoleVariables; + } + + \usleep(500000); + } self::$consoleVariables = $response['body'] ?? []; @@ -135,12 +144,20 @@ abstract class Scope extends TestCase return $this->getConsoleVariables()['supportForSchemas'] ?? true; } + /** + * Check if the database adapter supports attributes + */ + protected function getSupportForAttributes(): bool + { + return $this->getConsoleVariables()['supportForAttributes'] ?? true; + } + /** * Get the maximum index length supported by the database adapter */ protected function getMaxIndexLength(): int { - return $this->getConsoleVariables()['maxIndexLength'] ?? 768; + return $this->getConsoleVariables()['maxIndexLength'] ?? 767; } /** @@ -432,41 +449,67 @@ abstract class Scope extends TestCase return self::$root; } - // Use more entropy to avoid collisions in parallel test execution - $email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test'; - $password = 'password'; - $name = 'User Name'; + $maxRetries = 5; - $root = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - ], [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + // Use more entropy to avoid collisions in parallel test execution + $email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test'; + $password = 'password'; + $name = 'User Name'; - $this->assertEquals(201, $root['headers']['status-code']); + $root = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); - $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - ], [ - 'email' => $email, - 'password' => $password, - ]); + if ($root['headers']['status-code'] !== 201) { + \usleep(500000); + continue; + } - self::$root = [ - '$id' => ID::custom($root['body']['$id']), - 'name' => $root['body']['name'], - 'email' => $root['body']['email'], - 'session' => $session['cookies']['a_session_console'], - ]; + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], [ + 'email' => $email, + 'password' => $password, + ]); - return self::$root; + if (empty($session['cookies']['a_session_console'])) { + \usleep(500000); + continue; + } + + // Verify session is valid before returning + $verify = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $session['cookies']['a_session_console'], + 'x-appwrite-project' => 'console', + ]); + + if ($verify['headers']['status-code'] === 200) { + self::$root = [ + '$id' => ID::custom($root['body']['$id']), + 'name' => $root['body']['name'], + 'email' => $root['body']['email'], + 'session' => $session['cookies']['a_session_console'], + ]; + + return self::$root; + } + + \usleep(500000); + } + + $this->fail('Failed to create and verify root session after ' . $maxRetries . ' attempts'); } /** diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 2ae0b908d7..ea387cff6c 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2360,7 +2360,7 @@ class AccountCustomClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', @@ -2371,7 +2371,7 @@ class AccountCustomClientTest extends Scope 'password' => $password, ]); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); } @@ -2440,7 +2440,7 @@ class AccountCustomClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', @@ -2451,7 +2451,7 @@ class AccountCustomClientTest extends Scope 'password' => $password, ]); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(403, $response['headers']['status-code']); } public function testCreateJWT(): void diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 0c784d717f..b0b3339dac 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -68,6 +68,31 @@ trait DatabasesBase return self::$databaseCache[$cacheKey]; } + /** + * Helper to create an attribute on a collection. + * + * @param string $databaseId + * @param string $collectionId + * @param string $type + * @param array $payload + * + * @return array + */ + protected function createAttribute(string $databaseId, string $collectionId, string $type, array $payload): array + { + return $this->client->call( + Client::METHOD_POST, + $this->getSchemaUrl($databaseId, $collectionId) . '/' . $type, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + $payload + ); + } + + /** * Setup: Create database and collections * Uses static caching to avoid recreating resources @@ -150,75 +175,51 @@ trait DatabasesBase $data = $this->setupCollection(); $databaseId = $data['databaseId']; - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + if (!$this->getSupportForAttributes()) { + self::$attributesCache[$cacheKey] = $data; + return self::$attributesCache[$cacheKey]; + } + $title = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'title', 'size' => 256, 'required' => true, ]); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $description = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'description', 'size' => 512, 'required' => false, 'default' => '', ]); - $tagline = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $tagline = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'tagline', 'size' => 512, 'required' => false, 'default' => '', ]); - $releaseYear = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $releaseYear = $this->createAttribute($databaseId, $data['moviesId'], 'integer', [ 'key' => 'releaseYear', 'required' => true, 'min' => 1900, 'max' => 2200, ]); - $duration = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $duration = $this->createAttribute($databaseId, $data['moviesId'], 'integer', [ 'key' => 'duration', 'required' => false, 'min' => 60, ]); - $actors = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $actors = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'actors', 'size' => 256, 'required' => false, 'array' => true, ]); - $datetime = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/datetime', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $datetime = $this->createAttribute($databaseId, $data['moviesId'], 'datetime', [ 'key' => 'birthDay', 'required' => false, ]); @@ -933,6 +934,10 @@ trait DatabasesBase public function testCreateAttributes(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1182,6 +1187,10 @@ trait DatabasesBase public function testListAttributes(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $response = $this->client->call(Client::METHOD_GET, $this->getSchemaUrl($databaseId, $data['moviesId']), array_merge([ @@ -1210,6 +1219,10 @@ trait DatabasesBase public function testPatchAttribute(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1270,12 +1283,15 @@ trait DatabasesBase ]); $this->assertEquals(400, $attribute['headers']['status-code']); - $maxLength = $this->getMaxIndexLength(); - $this->assertStringContainsString('Index length is longer than the maximum: '.$maxLength, $attribute['body']['message']); + $this->assertStringContainsString('Index length is longer than the maximum:', $attribute['body']['message']); } public function testUpdateAttributeEnum(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1333,6 +1349,10 @@ trait DatabasesBase public function testAttributeResponseModels(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $collection = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), array_merge([ @@ -2044,65 +2064,75 @@ trait DatabasesBase $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; - // Create attributes needed for index testing - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'title', 'size' => 256, 'required' => true]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create attributes needed for index testing (only when supported). + // DocumentsDB can still create indexes without a predefined schema. + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'description', 'size' => 512, 'required' => false, 'default' => '']); - $this->assertEquals(202, $description['headers']['status-code']); + $description = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'description', + 'size' => 512, + 'required' => false, + 'default' => '', + ]); + $this->assertEquals(202, $description['headers']['status-code']); - $tagline = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'tagline', 'size' => 512, 'required' => false, 'default' => '']); - $this->assertEquals(202, $tagline['headers']['status-code']); + $tagline = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'tagline', + 'size' => 512, + 'required' => false, + 'default' => '', + ]); + $this->assertEquals(202, $tagline['headers']['status-code']); - $releaseYear = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'releaseYear', 'required' => true, 'min' => 1900, 'max' => 2200]); - $this->assertEquals(202, $releaseYear['headers']['status-code']); + $releaseYear = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'releaseYear', + 'required' => true, + 'min' => 1900, + 'max' => 2200, + ]); + $this->assertEquals(202, $releaseYear['headers']['status-code']); - $actors = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'actors', 'size' => 256, 'required' => false, 'array' => true]); - $this->assertEquals(202, $actors['headers']['status-code']); + $actors = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'actors', + 'size' => 256, + 'required' => false, + 'array' => true, + ]); + $this->assertEquals(202, $actors['headers']['status-code']); - $birthDay = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/datetime', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'birthDay', 'required' => false]); - $this->assertEquals(202, $birthDay['headers']['status-code']); + $birthDay = $this->createAttribute($databaseId, $collectionId, 'datetime', [ + 'key' => 'birthDay', + 'required' => false, + ]); + $this->assertEquals(202, $birthDay['headers']['status-code']); - $integers = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'integers', 'required' => false, 'array' => true, 'min' => 10, 'max' => 99]); - $this->assertEquals(202, $integers['headers']['status-code']); + $integers = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'integers', + 'required' => false, + 'array' => true, + 'min' => 10, + 'max' => 99, + ]); + $this->assertEquals(202, $integers['headers']['status-code']); - $integers2 = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'integers2', 'required' => false, 'array' => true, 'min' => 10, 'max' => 99]); - $this->assertEquals(202, $integers2['headers']['status-code']); + $integers2 = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'integers2', + 'required' => false, + 'array' => true, + 'min' => 10, + 'max' => 99, + ]); + $this->assertEquals(202, $integers2['headers']['status-code']); - // Wait for attributes to be ready - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attributes to be ready + $this->waitForAllAttributes($databaseId, $collectionId); + } $titleIndex = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -2219,13 +2249,16 @@ trait DatabasesBase $this->getIndexAttributesParam() => ['description', 'tagline'], ]); - if ($this->getMaxIndexLength() < 1024) { - // Only SQL-based adapters (MariaDB, PostgreSQL) enforce byte-level index length limits - $this->assertEquals(400, $tooLong['headers']['status-code']); - $this->assertStringContainsString('Index length is longer than the maximum', $tooLong['body']['message']); - } else { - // MongoDB (maxIndexLength=1024) doesn't exceed the limit with 512+512 - $this->assertEquals(202, $tooLong['headers']['status-code']); + // documentsdb isn't aware of the size so it will create + if ($this->getSupportForAttributes()) { + if ($this->getMaxIndexLength() < 1024) { + // Only SQL-based adapters (MariaDB, PostgreSQL) enforce byte-level index length limits + $this->assertEquals(400, $tooLong['headers']['status-code']); + $this->assertStringContainsString('Index length is longer than the maximum', $tooLong['body']['message']); + } else { + // MongoDB (maxIndexLength=1024) doesn't exceed the limit with 512+512 + $this->assertEquals(202, $tooLong['headers']['status-code']); + } } $fulltextArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ @@ -2239,119 +2272,126 @@ trait DatabasesBase ]); $this->assertEquals(400, $fulltextArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $fulltextArray['body']['message']); + $errorMessage = $this->getSupportForAttributes() ? "Creating indexes on array attributes is not currently supported." : "There is already a fulltext index in the collection"; + $this->assertEquals($errorMessage, $fulltextArray['body']['message']); - $actorsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-actors', - 'type' => 'key', - $this->getIndexAttributesParam() => ['actors'], - ]); + if ($this->getSupportForAttributes()) { - $this->assertEquals(400, $actorsArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $actorsArray['body']['message']); + $actorsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-actors', + 'type' => 'key', + $this->getIndexAttributesParam() => ['actors'], + ]); - $twoLevelsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-ip-actors', - 'type' => 'key', - $this->getIndexAttributesParam() => ['releaseYear', 'actors'], // 2 levels - 'orders' => ['DESC', 'DESC'], - ]); + $this->assertEquals(400, $actorsArray['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $actorsArray['body']['message']); - $this->assertEquals(400, $twoLevelsArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $twoLevelsArray['body']['message']); + $twoLevelsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-ip-actors', + 'type' => 'key', + $this->getIndexAttributesParam() => ['releaseYear', 'actors'], // 2 levels + 'orders' => ['DESC', 'DESC'], + ]); - $unknown = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-unknown', - 'type' => 'key', - $this->getIndexAttributesParam() => ['Unknown'], - ]); + $this->assertEquals(400, $twoLevelsArray['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $twoLevelsArray['body']['message']); - $this->assertEquals(400, $unknown['headers']['status-code']); - $this->assertStringContainsString('\'Unknown\' required for the index could not be found', $unknown['body']['message']); + $unknown = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-unknown', + 'type' => 'key', + $this->getIndexAttributesParam() => ['Unknown'], + ]); - $index1 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'integers-order', - 'type' => 'key', - $this->getIndexAttributesParam() => ['integers'], // array attribute - 'orders' => ['DESC'], // Check order is removed in API - ]); + $this->assertEquals(400, $unknown['headers']['status-code']); + $this->assertStringContainsString('\'Unknown\' required for the index could not be found', $unknown['body']['message']); + $index1 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'integers-order', + 'type' => 'key', + $this->getIndexAttributesParam() => ['integers'], // array attribute + 'orders' => ['DESC'], // Check order is removed in API + ]); - $this->assertEquals(400, $index1['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index1['body']['message']); + $this->assertEquals(400, $index1['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index1['body']['message']); - $index2 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'integers-size', - 'type' => 'key', - $this->getIndexAttributesParam() => ['integers2'], // array attribute - ]); + $index2 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'integers-size', + 'type' => 'key', + $this->getIndexAttributesParam() => ['integers2'], // array attribute + ]); - $this->assertEquals(400, $index2['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index2['body']['message']); + $this->assertEquals(400, $index2['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index2['body']['message']); - if (!$this->getSupportForMultipleFulltextIndexes()) { - // Some databases only allow one fulltext index per collection - $this->assertEquals('There is already a fulltext index in the collection', $fulltextReleaseYear['body']['message']); - } else { - $this->assertEquals('Attribute "releaseYear" cannot be part of a fulltext index, must be of type string', $fulltextReleaseYear['body']['message']); - } + if (!$this->getSupportForMultipleFulltextIndexes()) { + // Some databases only allow one fulltext index per collection + $this->assertEquals('There is already a fulltext index in the collection', $fulltextReleaseYear['body']['message']); + } else { + $this->assertEquals('Attribute "releaseYear" cannot be part of a fulltext index, must be of type string', $fulltextReleaseYear['body']['message']); + } - /** - * Create Indexes by worker - */ - $this->waitForAllIndexes($databaseId, $collectionId); + /** + * Create Indexes by worker + */ + $this->waitForAllIndexes($databaseId, $collectionId); - $collectionResponse = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), []); - - $this->assertIsArray($collectionResponse['body']['indexes']); - $expectedIndexCount = $this->getMaxIndexLength() < 1024 ? 4 : 5; // MongoDB accepts tooLong index - $this->assertCount($expectedIndexCount, $collectionResponse['body']['indexes']); - $indexKeys = array_column($collectionResponse['body']['indexes'], 'key'); - $this->assertContains($titleIndex['body']['key'], $indexKeys); - $this->assertContains($releaseYearIndex['body']['key'], $indexKeys); - $this->assertContains($releaseWithDate1['body']['key'], $indexKeys); - $this->assertContains($releaseWithDate2['body']['key'], $indexKeys); - - $this->assertEventually(function () use ($databaseId, $collectionId) { - $collResp = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ + $collectionResponse = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); + ]), []); - foreach ($collResp['body']['indexes'] as $index) { - $this->assertEquals('available', $index['status']); - } + $this->assertIsArray($collectionResponse['body']['indexes']); + $expectedIndexCount = $this->getMaxIndexLength() < 1024 ? 4 : 5; // MongoDB accepts tooLong index + $this->assertCount($expectedIndexCount, $collectionResponse['body']['indexes']); + $indexKeys = array_column($collectionResponse['body']['indexes'], 'key'); + $this->assertContains($titleIndex['body']['key'], $indexKeys); + $this->assertContains($releaseYearIndex['body']['key'], $indexKeys); + $this->assertContains($releaseWithDate1['body']['key'], $indexKeys); + $this->assertContains($releaseWithDate2['body']['key'], $indexKeys); - return true; - }, 60000, 500); + $this->assertEventually(function () use ($databaseId, $collectionId) { + $collResp = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + foreach ($collResp['body']['indexes'] as $index) { + $this->assertEquals('available', $index['status']); + } + + return true; + }, 60000, 500); + } } public function testGetIndexByKeyWithLengths(): void { + if (!$this->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $collectionId = $data['moviesId']; @@ -2545,6 +2585,7 @@ trait DatabasesBase $this->getRecordIdParam() => ID::unique(), 'data' => [ 'releaseYear' => 2020, // Missing title, expect an 400 error + 'birthDay' => null // adding null here as documentsdb will require it as for documentsdb this document will be created ], 'permissions' => [ Permission::read(Role::user($this->getUser()['$id'])), @@ -2564,7 +2605,11 @@ trait DatabasesBase $this->assertCount(2, $document1['body']['actors']); $this->assertEquals($document1['body']['actors'][0], 'Chris Evans'); $this->assertEquals($document1['body']['actors'][1], 'Samuel Jackson'); - $this->assertEquals($document1['body']['birthDay'], '1975-06-12T12:12:55.000+00:00'); + if ($this->getSupportForAttributes()) { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12T12:12:55.000+00:00'); + } else { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12 14:12:55+02:00'); + } $this->assertTrue(array_key_exists('$sequence', $document1['body'])); $this->getSupportForIntegerIds() @@ -2601,10 +2646,18 @@ trait DatabasesBase $this->assertCount(2, $document3['body']['actors']); $this->assertEquals($document3['body']['actors'][0], 'Tom Holland'); $this->assertEquals($document3['body']['actors'][1], 'Zendaya Maree Stoermer'); - $this->assertEquals($document3['body']['birthDay'], '1975-06-12T18:12:55.000+00:00'); // UTC for NY + if ($this->getSupportForAttributes()) { + $this->assertEquals($document3['body']['birthDay'], '1975-06-12T18:12:55.000+00:00'); // UTC for NY + } else { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12 14:12:55+02:00'); + } $this->assertTrue(array_key_exists('$sequence', $document3['body'])); - $this->assertEquals(400, $document4['headers']['status-code']); + if ($this->getSupportForAttributes()) { + $this->assertEquals(400, $document4['headers']['status-code']); + } else { + $this->assertEquals(201, $document4['headers']['status-code']); + } } public function testUpsertDocument(): void @@ -3269,6 +3322,10 @@ trait DatabasesBase public function testListDocumentsWithCache(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; $docIds = $data['documentIds']; @@ -3399,6 +3456,10 @@ trait DatabasesBase public function testListDocumentsCacheBustedByAttributeChange(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; $docIds = $data['documentIds']; @@ -3979,34 +4040,43 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertEquals(0, $documents['body']['total']); - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::greaterThan('birthDay', '16/01/2024 12:00:00AM')->toString(), - ], - ]); + // for tablesdb/legacy it is full match , for docsdb inner pattern is matched + if ($this->getSupportForAttributes()) { + $this->assertEquals(0, $documents['body']['total']); + } else { + $this->assertGreaterThan(0, $documents['body']['total']); + } - $this->assertEquals(400, $documents['headers']['status-code']); - $this->assertEquals('Invalid query: Query value is invalid for attribute "birthDay"', $documents['body']['message']); + if ($this->getSupportForAttributes()) { + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('birthDay', '16/01/2024 12:00:00AM')->toString(), + ], + ]); - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::greaterThan('birthDay', '1960-01-01 10:10:10+02:30')->toString(), - ], - ]); + $this->assertEquals(400, $documents['headers']['status-code']); + $this->assertEquals('Invalid query: Query value is invalid for attribute "birthDay"', $documents['body']['message']); + + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('birthDay', '1960-01-01 10:10:10+02:30')->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual(2, count($documents['body'][$this->getRecordResource()])); + $birthDays = array_column($documents['body'][$this->getRecordResource()], 'birthDay'); + $this->assertContains('1975-06-12T12:12:55.000+00:00', $birthDays); + $this->assertContains('1975-06-12T18:12:55.000+00:00', $birthDays); + } - $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertGreaterThanOrEqual(2, count($documents['body'][$this->getRecordResource()])); - $birthDays = array_column($documents['body'][$this->getRecordResource()], 'birthDay'); - $this->assertContains('1975-06-12T12:12:55.000+00:00', $birthDays); - $this->assertContains('1975-06-12T18:12:55.000+00:00', $birthDays); $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ 'content-type' => 'application/json', @@ -4650,6 +4720,10 @@ trait DatabasesBase public function testInvalidDocumentStructure(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -5350,22 +5424,18 @@ trait DatabasesBase $this->assertEquals($collection['body'][$this->getSecurityResponseKey()], true); $collectionId = $collection['body']['$id']; + if ($this->getSupportForAttributes()) { + $attribute = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code'], 202); + $this->assertEquals('attribute', $attribute['body']['key']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'attribute', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEquals(202, $attribute['headers']['status-code'], 202); - $this->assertEquals('attribute', $attribute['body']['key']); - - // wait for db to add attribute - $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + // wait for db to add attribute + $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + } $index = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -5374,7 +5444,7 @@ trait DatabasesBase ]), [ 'key' => 'key_attribute', 'type' => 'key', - $this->getIndexAttributesParam() => [$attribute['body']['key']], + $this->getIndexAttributesParam() => ['attribute'], ]); $this->assertEquals(202, $index['headers']['status-code']); @@ -5540,21 +5610,17 @@ trait DatabasesBase $this->assertEquals($collection['body'][$this->getSecurityResponseKey()], false); $collectionId = $collection['body']['$id']; + if ($this->getSupportForAttributes()) { + $attribute = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code'], 202); + $this->assertEquals('attribute', $attribute['body']['key']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'attribute', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEquals(202, $attribute['headers']['status-code'], 202); - $this->assertEquals('attribute', $attribute['body']['key']); - - $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + } $index = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -5563,7 +5629,7 @@ trait DatabasesBase ]), [ 'key' => 'key_attribute', 'type' => 'key', - $this->getIndexAttributesParam() => [$attribute['body']['key']], + $this->getIndexAttributesParam() => ['attribute'], ]); $this->assertEquals(202, $index['headers']['status-code'], 'Index creation failed: ' . json_encode($index['body'] ?? [])); @@ -5930,21 +5996,18 @@ trait DatabasesBase $moviesId = $movies['body']['$id']; // create attribute - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $moviesId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $moviesId, 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); - $this->assertEquals(202, $title['headers']['status-code']); - - // wait for database worker to create attributes - $this->waitForAttribute($databaseId, $moviesId, 'title'); + $this->assertEquals(202, $title['headers']['status-code']); + // wait for database worker to create attributes + $this->waitForAttribute($databaseId, $moviesId, 'title'); + } // add document $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $moviesId), array_merge([ 'content-type' => 'application/json', @@ -6009,6 +6072,11 @@ trait DatabasesBase public function testAttributeBooleanDefault(): void { + if (!$this->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -6895,32 +6963,25 @@ trait DatabasesBase $this->assertEquals(201, $presidents['headers']['status-code']); $this->assertEquals($presidents['body']['name'], 'USA Presidents'); - // Create Attributes - $firstName = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $presidents['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'first_name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $firstName['headers']['status-code']); + // Create Attributes (only for adapters that support attributes) + if ($this->getSupportForAttributes()) { + $firstName = $this->createAttribute($databaseId, $presidents['body']['$id'], 'string', [ + 'key' => 'first_name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $firstName['headers']['status-code']); - $lastName = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $presidents['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'last_name', - 'size' => 256, - 'required' => true, - ]); + $lastName = $this->createAttribute($databaseId, $presidents['body']['$id'], 'string', [ + 'key' => 'last_name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $lastName['headers']['status-code']); - $this->assertEquals(202, $lastName['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $presidents['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $presidents['body']['$id']); + } $document1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $presidents['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -7125,20 +7186,19 @@ trait DatabasesBase 'databaseId' => $databaseId, ]; - $longtext = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($data['databaseId'], $data['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'longtext', - 'size' => 100000000, - 'required' => false, - 'default' => null, - ]); + // Create attribute only on adapters that support attributes; DocumentsDB can still store the field schemalessly + if ($this->getSupportForAttributes()) { + $longtext = $this->createAttribute($data['databaseId'], $data['$id'], 'string', [ + 'key' => 'longtext', + 'size' => 100000000, + 'required' => false, + 'default' => null, + ]); - $this->assertEquals($longtext['headers']['status-code'], 202); + $this->assertEquals(202, $longtext['headers']['status-code']); - $this->waitForAttribute($data['databaseId'], $data['$id'], 'longtext'); + $this->waitForAttribute($data['databaseId'], $data['$id'], 'longtext'); + } for ($i = 0; $i < 10; $i++) { $this->client->call(Client::METHOD_POST, $this->getRecordUrl($data['databaseId'], $data['$id']), array_merge([ @@ -7206,17 +7266,20 @@ trait DatabasesBase ]); $collectionId = $collection['body']['$id']; - // Add integer attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'count', - 'required' => true, - ]); + // Add integer attribute only when supported; schemaless adapters (e.g. documentsdb) + // can still store the field without a predefined attribute. + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + ]); - $this->waitForAttribute($databaseId, $collectionId, 'count'); + $this->waitForAttribute($databaseId, $collectionId, 'count'); + } // Create document with initial count = 5 $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -7281,7 +7344,10 @@ trait DatabasesBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); - $this->assertEquals(404, $notFound['headers']['status-code']); + $this->assertEquals( + $this->getSupportForAttributes() ? 404 : 200, + $notFound['headers']['status-code'] + ); // Test increment with value 0 $inc3 = $this->client->call(Client::METHOD_PATCH, $this->getRecordUrl($databaseId, $collectionId, $docId) . "/count/increment", array_merge([ @@ -7322,17 +7388,20 @@ trait DatabasesBase $collectionId = $collection['body']['$id']; - // Add integer attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'count', - 'required' => true, - ]); + // Add integer attribute only when supported; schemaless adapters can still + // store the field without a predefined attribute. + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + ]); - $this->waitForAttribute($databaseId, $collectionId, 'count'); + $this->waitForAttribute($databaseId, $collectionId, 'count'); + } // Create document with initial count = 10 $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -9754,32 +9823,25 @@ trait DatabasesBase $this->assertEquals(201, $movies['headers']['status-code']); $this->assertEquals($movies['body']['name'], 'Movies'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $movies['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported; DocumentsDB can still store fields schemalessly) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $movies['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $genre = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $movies['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'genre', - 'size' => 256, - 'required' => true, - ]); + $genre = $this->createAttribute($databaseId, $movies['body']['$id'], 'string', [ + 'key' => 'genre', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $genre['headers']['status-code']); - $this->assertEquals(202, $genre['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $movies['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $movies['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $movies['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -9922,31 +9984,24 @@ trait DatabasesBase $this->assertEquals(201, $products['headers']['status-code']); $this->assertEquals($products['body']['name'], 'Products'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $products['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $price = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/float', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'price', - 'required' => true, - ]); + $price = $this->createAttribute($databaseId, $products['body']['$id'], 'float', [ + 'key' => 'price', + 'required' => true, + ]); + $this->assertEquals(202, $price['headers']['status-code']); - $this->assertEquals(202, $price['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $products['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $products['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $products['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10054,32 +10109,25 @@ trait DatabasesBase $this->assertEquals(201, $employees['headers']['status-code']); $this->assertEquals($employees['body']['name'], 'Employees'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $employees['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $employees['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $department = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $employees['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'department', - 'size' => 256, - 'required' => true, - ]); + $department = $this->createAttribute($databaseId, $employees['body']['$id'], 'string', [ + 'key' => 'department', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $department['headers']['status-code']); - $this->assertEquals(202, $department['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $employees['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $employees['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $employees['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10187,32 +10235,25 @@ trait DatabasesBase $this->assertEquals(201, $files['headers']['status-code']); $this->assertEquals($files['body']['name'], 'Files'); - // Create Attributes - $filename = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $files['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'filename', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $filename['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $filename = $this->createAttribute($databaseId, $files['body']['$id'], 'string', [ + 'key' => 'filename', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $filename['headers']['status-code']); - $type = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $files['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 256, - 'required' => true, - ]); + $type = $this->createAttribute($databaseId, $files['body']['$id'], 'string', [ + 'key' => 'type', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $type['headers']['status-code']); - $this->assertEquals(202, $type['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $files['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $files['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $files['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10320,32 +10361,25 @@ trait DatabasesBase $this->assertEquals(201, $posts['headers']['status-code']); $this->assertEquals($posts['body']['name'], 'Posts'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $posts['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $posts['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $content = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $posts['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'content', - 'size' => 512, - 'required' => true, - ]); + $content = $this->createAttribute($databaseId, $posts['body']['$id'], 'string', [ + 'key' => 'content', + 'size' => 512, + 'required' => true, + ]); + $this->assertEquals(202, $content['headers']['status-code']); - $this->assertEquals(202, $content['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $posts['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $posts['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $posts['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10460,32 +10494,25 @@ trait DatabasesBase $this->assertEquals(201, $events['headers']['status-code']); $this->assertEquals($events['body']['name'], 'Events'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $events['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $events['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $events['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'description', - 'size' => 512, - 'required' => true, - ]); + $description = $this->createAttribute($databaseId, $events['body']['$id'], 'string', [ + 'key' => 'description', + 'size' => 512, + 'required' => true, + ]); + $this->assertEquals(202, $description['headers']['status-code']); - $this->assertEquals(202, $description['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $events['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $events['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $events['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10600,31 +10627,25 @@ trait DatabasesBase $this->assertEquals(201, $articles['headers']['status-code']); $this->assertEquals($articles['body']['name'], 'Articles'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $articles['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $articles['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $content = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $articles['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'content', - 'size' => 5000, - 'required' => true, - ]); - $this->assertEquals(202, $content['headers']['status-code']); + $content = $this->createAttribute($databaseId, $articles['body']['$id'], 'string', [ + 'key' => 'content', + 'size' => 5000, + 'required' => true, + ]); + $this->assertEquals(202, $content['headers']['status-code']); - // Wait for attributes to be available - $this->waitForAllAttributes($databaseId, $articles['body']['$id']); + // Wait for attributes to be available + $this->waitForAllAttributes($databaseId, $articles['body']['$id']); + } // Create first article $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $articles['body']['$id']), array_merge([ @@ -10785,32 +10806,25 @@ trait DatabasesBase $this->assertEquals(201, $tasks['headers']['status-code']); $this->assertEquals($tasks['body']['name'], 'Tasks'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tasks['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $tasks['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $status = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tasks['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $status = $this->createAttribute($databaseId, $tasks['body']['$id'], 'string', [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $status['headers']['status-code']); - $this->assertEquals(202, $status['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $tasks['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $tasks['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tasks['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10958,32 +10972,25 @@ trait DatabasesBase $this->assertEquals(201, $orders['headers']['status-code']); $this->assertEquals($orders['body']['name'], 'Orders'); - // Create Attributes - $orderNumber = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $orders['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'orderNumber', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $orderNumber['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $orderNumber = $this->createAttribute($databaseId, $orders['body']['$id'], 'string', [ + 'key' => 'orderNumber', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $orderNumber['headers']['status-code']); - $status = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $orders['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $status = $this->createAttribute($databaseId, $orders['body']['$id'], 'string', [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $status['headers']['status-code']); - $this->assertEquals(202, $status['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $orders['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $orders['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $orders['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -11130,30 +11137,24 @@ trait DatabasesBase $this->assertEquals(201, $products['headers']['status-code']); $this->assertEquals($products['body']['name'], 'Products'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $products['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $price = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/float', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'price', - 'required' => true, - ]); - $this->assertEquals(202, $price['headers']['status-code']); + $price = $this->createAttribute($databaseId, $products['body']['$id'], 'float', [ + 'key' => 'price', + 'required' => true, + ]); + $this->assertEquals(202, $price['headers']['status-code']); - // Wait for attributes to be available - $this->waitForAllAttributes($databaseId, $products['body']['$id']); + // Wait for attributes to be available + $this->waitForAllAttributes($databaseId, $products['body']['$id']); + } // Create first product $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $products['body']['$id']), array_merge([ diff --git a/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php b/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php new file mode 100644 index 0000000000..1fdcc84d0c --- /dev/null +++ b/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php @@ -0,0 +1,362 @@ +client->call( + 'POST', + '/documentsdb', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Indexes', + ] + ); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $movies = $this->client->call( + 'POST', + '/documentsdb/' . $databaseId . '/collections', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + ] + ); + + $this->assertEquals(201, $movies['headers']['status-code']); + $moviesId = $movies['body']['$id']; + + $titleIndex = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'titleIndex', + 'type' => 'fulltext', + 'attributes' => ['title'], + ]); + + $this->assertEquals(202, $titleIndex['headers']['status-code']); + $this->assertEquals('titleIndex', $titleIndex['body']['key']); + $this->assertEquals('fulltext', $titleIndex['body']['type']); + $this->assertCount(1, $titleIndex['body']['attributes']); + $this->assertEquals('title', $titleIndex['body']['attributes'][0]); + + $releaseYearIndex = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYear', + 'type' => 'key', + 'attributes' => ['releaseYear'], + ]); + + $this->assertEquals(202, $releaseYearIndex['headers']['status-code']); + $this->assertEquals('releaseYear', $releaseYearIndex['body']['key']); + $this->assertEquals('key', $releaseYearIndex['body']['type']); + $this->assertCount(1, $releaseYearIndex['body']['attributes']); + $this->assertEquals('releaseYear', $releaseYearIndex['body']['attributes'][0]); + + $releaseWithDate1 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYearDated', + 'type' => 'key', + 'attributes' => ['releaseYear', '$createdAt', '$updatedAt'], + ]); + + $this->assertEquals(202, $releaseWithDate1['headers']['status-code']); + $this->assertEquals('releaseYearDated', $releaseWithDate1['body']['key']); + $this->assertEquals('key', $releaseWithDate1['body']['type']); + $this->assertCount(3, $releaseWithDate1['body']['attributes']); + $this->assertEquals('releaseYear', $releaseWithDate1['body']['attributes'][0]); + $this->assertEquals('$createdAt', $releaseWithDate1['body']['attributes'][1]); + $this->assertEquals('$updatedAt', $releaseWithDate1['body']['attributes'][2]); + + $releaseWithDate2 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'birthDay', + 'type' => 'key', + 'attributes' => ['birthDay'], + ]); + + $this->assertEquals(202, $releaseWithDate2['headers']['status-code']); + $this->assertEquals('birthDay', $releaseWithDate2['body']['key']); + $this->assertEquals('key', $releaseWithDate2['body']['type']); + $this->assertCount(1, $releaseWithDate2['body']['attributes']); + $this->assertEquals('birthDay', $releaseWithDate2['body']['attributes'][0]); + + // Failure cases + $fulltextReleaseYear = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYearDated', + 'type' => 'fulltext', + 'attributes' => ['releaseYear'], + ]); + $this->assertEquals(400, $fulltextReleaseYear['headers']['status-code']); + + $noAttributes = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'none', + 'type' => 'key', + 'attributes' => [], + ]); + $this->assertEquals(400, $noAttributes['headers']['status-code']); + + $duplicates = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'duplicate', + 'type' => 'fulltext', + 'attributes' => ['releaseYear', 'releaseYear'], + ]); + $this->assertEquals(400, $duplicates['headers']['status-code']); + + $tooLong = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'tooLong', + 'type' => 'key', + 'attributes' => ['description', 'tagline'], + ]); + $this->assertEquals(202, $tooLong['headers']['status-code']); + + $fulltextArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'ft', + 'type' => 'fulltext', + 'attributes' => ['actors'], + ]); + $this->assertEquals(400, $fulltextArray['headers']['status-code']); + + $actorsArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-actors', + 'type' => 'key', + 'attributes' => ['actors'], + ]); + $this->assertEquals(202, $actorsArray['headers']['status-code']); + + $twoLevelsArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-ip-actors', + 'type' => 'key', + 'attributes' => ['releaseYear', 'actors'], + 'orders' => ['DESC', 'DESC'], + ]); + $this->assertEquals(202, $twoLevelsArray['headers']['status-code']); + + $unknown = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-unknown', + 'type' => 'key', + 'attributes' => ['Unknown'], + ]); + $this->assertEquals(202, $unknown['headers']['status-code']); + + $index1 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'integers-order', + 'type' => 'key', + 'attributes' => ['integers'], + 'orders' => ['DESC'], + ]); + $this->assertEquals(202, $index1['headers']['status-code']); + + $index2 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'integers-size', + 'type' => 'key', + 'attributes' => ['integers'], + ]); + $this->assertEquals(202, $index2['headers']['status-code']); + + // Let worker create indexes + sleep(2); + + $moviesWithIndexes = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$moviesId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertIsArray($moviesWithIndexes['body']['indexes']); + $this->assertCount(10, $moviesWithIndexes['body']['indexes']); + + $this->assertEventually(function () use ($databaseId, $moviesId) { + $movies = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$moviesId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + foreach ($movies['body']['indexes'] as $index) { + $this->assertEquals('available', $index['status']); + } + + return true; + }, 60000, 500); + } + + public function testGetIndexByKeyWithLengths(): void + { + $database = $this->client->call( + 'POST', + '/documentsdb', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Index Lengths', + ] + ); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call( + 'POST', + "/documentsdb/{$databaseId}/collections", + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + ] + ); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthTestIndex', + 'type' => 'key', + 'attributes' => ['title', 'description'], + 'lengths' => [128, 200], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $index = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes/lengthTestIndex", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('lengthTestIndex', $index['body']['key']); + $this->assertEquals([128, 200], $index['body']['lengths']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthOverrideTestIndex', + 'type' => 'key', + 'attributes' => ['actors-new'], + 'lengths' => [Database::MAX_ARRAY_INDEX_LENGTH], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $index = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes/lengthOverrideTestIndex", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals([Database::MAX_ARRAY_INDEX_LENGTH], $index['body']['lengths']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthCountExceededIndex', + 'type' => 'key', + 'attributes' => ['title-not-throw-error'], + 'lengths' => [128, 128], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthTooLargeIndex', + 'type' => 'key', + 'attributes' => ['title', 'description', 'tagline', 'actors'], + 'lengths' => [256, 256, 256, 20], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php b/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php new file mode 100644 index 0000000000..895cf67490 --- /dev/null +++ b/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php @@ -0,0 +1,16 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $database = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), + [ + 'databaseId' => ID::unique(), + 'name' => 'InvalidDocumentDatabase', + ] + ); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('InvalidDocumentDatabase', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + $this->assertEquals(201, $publicMovies['headers']['status-code']); + + $privateMovies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $privateMovies['headers']['status-code']); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $publicCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $publicCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + $privateDocuments = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $privateCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $recordKey = $this->getRecordResource(); + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body'][$recordKey][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body'][$recordKey][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $publicCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $publicCollectionId, $publicDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ] + ); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['title']); + + $privateDocument = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $privateCollectionId, $privateDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ] + ); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call( + Client::METHOD_DELETE, + $this->getRecordUrl($databaseId, $publicCollectionId, $publicDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call( + Client::METHOD_DELETE, + $this->getRecordUrl($databaseId, $privateCollectionId, $privateDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $database = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), + [ + 'databaseId' => ID::unique(), + 'name' => 'GuestPermissionsWrite', + ] + ); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('GuestPermissionsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::create(Role::any()), + ], + $this->getSecurityParam() => true, + ] + ); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $moviesId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ] + ); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php new file mode 100644 index 0000000000..88e84b017d --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php @@ -0,0 +1,238 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('random')))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 1, 1, 0], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 1, 1, 0], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 1, 1, 1], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('user1')))], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 1, 1, 1], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 1, 1, 1], + ]; + } + + /** + * Setup database helper + */ + protected function setupDatabase(): array + { + $cacheKey = $this->getProject()['$id'] . '_' . static::class; + + if (!empty(self::$setupDatabaseCache[$cacheKey])) { + return self::$setupDatabaseCache[$cacheKey]; + } + + $this->createUsers(); + + $db = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + $this->getServerHeader(), + [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ] + ); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Private Movies', + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Document Only Movies', + 'permissions' => [], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $doconly['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + self::$setupDatabaseCache[$cacheKey] = [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId, + ]; + + return self::$setupDatabaseCache[$cacheKey]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount) + { + $data = $this->setupDatabase(); + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['public']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['private']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['doconly']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['public']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['private']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['doconly']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php new file mode 100644 index 0000000000..db9adf9bb1 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php @@ -0,0 +1,234 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + $this->getServerHeader(), + [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ] + ); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($this->databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::custom('collection1'), + 'name' => 'Collection 1', + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ] + ); + $this->assertEquals(201, $collection1['headers']['status-code']); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($this->databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::custom('collection2'), + 'name' => 'Collection 2', + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ], + ] + ); + $this->assertEquals(201, $collection2['headers']['status-code']); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database helper + */ + protected function setupDatabase(): array + { + $cacheKey = $this->getProject()['$id'] . '_' . static::class; + + if (!empty(self::$setupDatabaseCache[$cacheKey])) { + return self::$setupDatabaseCache[$cacheKey]; + } + + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $this->collections['collection1']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $this->collections['collection2']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Ipsum', + ], + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + self::$setupDatabaseCache[$cacheKey] = $this->users; + + return self::$setupDatabaseCache[$cacheKey]; + } + + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success) + { + $users = $this->setupDatabase(); + + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($this->databaseId, $collection), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ] + ); + + if ($success) { + $this->assertCount(1, $documents['body'][$this->getRecordResource()]); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success) + { + $users = $this->setupDatabase(); + + $documents = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $collection), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Ipsum', + ], + ] + ); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php new file mode 100644 index 0000000000..52ddcc8586 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php @@ -0,0 +1,281 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestDB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestDB', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $privateMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + $privateDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body']['documents'][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body']['documents'][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ] + ]); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['metadata']['title']); + + $privateDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestPermsWrite', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestPermsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + ], + 'documentSecurity' => true + ]); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + 'permissions' => [ + Permission::read(Role::any()), + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['metadata']['title']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php new file mode 100644 index 0000000000..3043a42dd5 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php @@ -0,0 +1,197 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 2, 2, 2], + [[Permission::read(Role::user(ID::custom('random')))], 3, 3, 2], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 4, 4, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 5, 5, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 6, 6, 2], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 7, 7, 2], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 8, 8, 3], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 9, 9, 4], + [[Permission::read(Role::user(ID::custom('user1')))], 10, 10, 5], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 11, 11, 6], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 12, 12, 7], + ]; + } + + /** + * Setup database + * + * Data providers lose object state so explicitly pass [$users, $collections] to each iteration + * + * @return array + * @throws \Exception + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Private Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Document Only Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + return [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId + ]; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[DataProvider('permissionsProvider')] + #[Depends('testSetupDatabase')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount, $data) + { + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php new file mode 100644 index 0000000000..11709ed729 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php @@ -0,0 +1,200 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection1'), + 'name' => 'Collection 1', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ]); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection2'), + 'name' => 'Collection 2', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ] + ]); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database + * + * Data providers lose object state + * so explicitly pass $users to each iteration + * @return array $users + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection1'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection2'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + return $this->users; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[Depends('testSetupDatabase')] + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ]); + + if ($success) { + $this->assertCount(1, $documents['body']['documents']); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[Depends('testSetupDatabase')] + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 070b83734f..1a6ee83b33 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -47,18 +47,20 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add unique attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'email', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + // Add unique attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add unique index $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ @@ -174,6 +176,11 @@ trait ACIDBase */ public function testConsistency(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); + return; + } + // Create database $database = $this->client->call(Client::METHOD_POST, $this->getDatabaseUrl(), array_merge([ 'content-type' => 'application/json', @@ -336,19 +343,21 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add counter attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => true, - 'min' => 0, - 'max' => 1000000 - ]); + if ($this->getSupportForAttributes()) { + // Add counter attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => true, + 'min' => 0, + 'max' => 1000000 + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create initial document with counter $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -494,18 +503,20 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + // Add attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create and commit transaction with multiple operations $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ diff --git a/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php b/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php new file mode 100644 index 0000000000..eb597e3488 --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php @@ -0,0 +1,18 @@ +assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -150,18 +152,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document first with API key $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -224,18 +228,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -297,18 +303,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document with update permission at document level $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -376,18 +384,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document with delete permission at document level $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -457,18 +467,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); // Add attribute - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -527,18 +539,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -597,18 +611,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -660,18 +676,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -723,18 +741,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1060,18 +1080,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create user 1 (fresh) and their transaction $user1 = $this->getUser(true); diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index 479e4d5c68..c69c35f663 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -70,19 +70,21 @@ trait TransactionsBase $this->assertEquals(201, $collection['headers']['status-code']); self::$sharedCollectionId = $collection['body']['$id']; - // Create a standard 'name' attribute - $nameAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, self::$sharedCollectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $nameAttr['headers']['status-code']); + // Create a standard 'name' attribute only if attributes are supported + if ($this->getSupportForAttributes()) { + $nameAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, self::$sharedCollectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $nameAttr['headers']['status-code']); - $this->waitForAllAttributes($databaseId, self::$sharedCollectionId); + $this->waitForAllAttributes($databaseId, self::$sharedCollectionId); + } return self::$sharedCollectionId; } @@ -219,20 +221,22 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); + $this->assertEquals(202, $attribute['headers']['status-code']); - // Wait for attribute to be created - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attribute to be created + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add valid operations $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ @@ -365,18 +369,20 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -517,17 +523,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'value', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'value', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add operations $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ @@ -607,17 +615,18 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => false, - ]); - - $this->waitForAllAttributes($databaseId, $collectionId); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction with minimum TTL (60 seconds) $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -697,17 +706,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'value', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'value', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -819,19 +830,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $counterAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => true, - 'min' => 0, - 'max' => 1000000, - ]); - $this->assertEquals(202, $counterAttr['headers']['status-code']); + if ($this->getSupportForAttributes()) { + $counterAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => true, + 'min' => 0, + 'max' => 1000000, + ]); + $this->assertEquals(202, $counterAttr['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -959,17 +972,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -1064,27 +1079,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some initial documents for ($i = 1; $i <= 5; $i++) { @@ -1228,17 +1245,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes with constraints - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'email', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create unique index on email $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId, null), array_merge([ @@ -1361,18 +1380,20 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Test double commit $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1485,18 +1506,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1595,20 +1619,22 @@ trait TransactionsBase ['key' => 'data', 'type' => 'string', 'size' => 256, 'required' => false], ]; - foreach ($attributes as $attr) { - $type = $attr['type']; - unset($attr['type']); + if ($this->getSupportForAttributes()) { + foreach ($attributes as $attr) { + $type = $attr['type']; + unset($attr['type']); - $response = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, $type, null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), $attr); + $response = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, $type, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), $attr); - $this->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals(202, $response['headers']['status-code']); + } + $this->waitForAllAttributes($databaseId, $collectionId); } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1699,38 +1725,40 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create document outside transaction $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -1836,28 +1864,30 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2031,27 +2061,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2175,27 +2207,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create documents for bulk testing for ($i = 1; $i <= 3; $i++) { @@ -2299,28 +2333,30 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create one document outside transaction $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -2445,27 +2481,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create documents for bulk testing for ($i = 1; $i <= 3; $i++) { @@ -2569,38 +2607,40 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - 'min' => 1, - 'max' => 10, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + 'min' => 1, + 'max' => 10, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create an existing document outside transaction for testing $existingDoc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -2848,18 +2888,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2990,36 +3033,38 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'age', - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some existing documents for ($i = 1; $i <= 3; $i++) { @@ -3170,37 +3215,39 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 4; $i++) { @@ -3345,27 +3392,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 3; $i++) { @@ -3507,27 +3556,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 5; $i++) { @@ -3663,28 +3714,31 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Add integer attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add integer attributes + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'default' => 0, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - 'default' => 100, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + 'default' => 100, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -3822,18 +3876,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Add balance attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'balance', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add balance attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'balance', + 'required' => false, + 'default' => 0, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -3965,27 +4022,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'category', - 'size' => 50, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 50, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents for ($i = 1; $i <= 5; $i++) { @@ -4107,26 +4166,28 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 100, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 100, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'value', - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'value', + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some initial documents $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -4266,26 +4327,28 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 50, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents for ($i = 1; $i <= 10; $i++) { @@ -4405,20 +4468,22 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add required attribute - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string', null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); + $this->assertEquals(202, $attribute['headers']['status-code']); - // Wait for attribute to be ready - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attribute to be ready + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -4852,27 +4917,29 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Add columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'default' => 0, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create initial row $row = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ @@ -4987,18 +5054,21 @@ trait TransactionsBase $tableId = $table['body']['$id']; - // Add balance column - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'balance', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add balance column + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'balance', + 'required' => false, + 'default' => 0, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); // Create initial row $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ @@ -5095,17 +5165,19 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Add columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -5205,17 +5277,20 @@ trait TransactionsBase $tableId = $table['body']['$id']; - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 50, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5309,17 +5384,20 @@ trait TransactionsBase $tableId = $table['body']['$id']; - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5427,17 +5505,20 @@ trait TransactionsBase 'required' => true, ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'flag', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'flag', + 'size' => 256, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5543,28 +5624,30 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Create columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -5687,19 +5770,21 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Create array column - $column = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'items', - 'size' => 255, - 'required' => false, - 'array' => true, - ]); + if ($this->getSupportForAttributes()) { + $column = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'items', + 'size' => 255, + 'required' => false, + 'array' => true, + ]); - $this->assertEquals(202, $column['headers']['status-code']); - $this->waitForAllAttributes($databaseId, $tableId); + $this->assertEquals(202, $column['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create initial row with some items $row = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ diff --git a/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php b/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php new file mode 100644 index 0000000000..914c8d0c5b --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php @@ -0,0 +1,528 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'AtomicityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection for the test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'AtomicityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a document outside the transaction + $existingDocumentId = 'existing_doc'; + $doc1 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $existingDocumentId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'], + ], + ]); + + $this->assertEquals(201, $doc1['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed. Response: ' . json_encode($transaction)); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id. Response body: ' . json_encode($transaction['body'])); + $transactionId = $transaction['body']['$id']; + + // Add operations - second create reuses an existing documentId and should cause the commit to fail + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'txn_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'newuser@example.com'], + ], + [ + '$id' => $existingDocumentId, + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'duplicate@example.com'], + ], + [ + '$id' => 'txn_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'should-not-exist@example.com'], + ], + ], + 'transactionId' => $transactionId, + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Adding documents via normal route should succeed. Response: ' . json_encode($response['body'])); + + // Attempt to commit - should fail due to duplicate document ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test consistency - schema validation and constraints + */ + public function testConsistency(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConsistencyTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'ConsistencyTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $transactionId = $transaction['body']['$id']; + + // Stage operations with valid and invalid data (embedding length mismatch) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Valid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(2, 0.5), // Invalid dimensions + 'metadata' => ['name' => 'Invalid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.6), + 'metadata' => ['name' => 'Should Not Persist'], + ], + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Attempt to commit - should fail due to invalid embeddings + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertContains($response['headers']['status-code'], [400, 409, 500], 'Transaction commit should fail due to validation. Response: ' . json_encode($response['body'])); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test isolation - concurrent transactions on same data + */ + public function testIsolation(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsolationTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'IsolationTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document with status metadata + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['status' => 'pending'], + ], + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create first transaction + $transaction1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction1['headers']['status-code'], 'Transaction 1 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction1['body'], 'Transaction 1 response should have $id'); + $transactionId1 = $transaction1['body']['$id']; + + // Transaction 1: update status to approved + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'approved'], + ], + ], + ], + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + $this->assertEquals(200, $response1['headers']['status-code']); + + // Document should reflect the first transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('approved', $document['body']['metadata']['status']); + + // Create second transaction after first commit + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction2['headers']['status-code'], 'Transaction 2 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction2['body'], 'Transaction 2 response should have $id'); + $transactionId2 = $transaction2['body']['$id']; + + // Transaction 2: update status to declined + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'declined'], + ], + ], + ], + ]); + + // Commit second transaction and ensure isolation guarantees + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response2['headers']['status-code']); + + // Final document should reflect the second transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('declined', $document['body']['metadata']['status']); + } + + /** + * Test durability - committed data persists + */ + public function testDurability(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DurabilityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'DurabilityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction with multiple operations + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed'); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id'); + $transactionId = $transaction['body']['$id']; + + // Create two documents via normal route inside transaction + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'durable_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['data' => 'Important data 1'], + ], + [ + '$id' => 'durable_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['data' => 'Important data 2'], + ], + ], + 'transactionId' => $transactionId, + ]); + + // Update first document inside the same transaction + $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => ['data' => 'Updated important data 1'], + ], + 'transactionId' => $transactionId, + ]); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Commit should succeed. Response: ' . json_encode($response['body'])); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents exist and have correct data + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document1['headers']['status-code']); + $this->assertEquals('Updated important data 1', $document1['body']['metadata']['data']); + + $document2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_2", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document2['headers']['status-code']); + $this->assertEquals('Important data 2', $document2['body']['metadata']['data']); + + // Further update outside transaction to ensure persistence + $update = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'metadata' => ['data' => 'Modified outside transaction'], + ], + ]); + $this->assertEquals(200, $update['headers']['status-code']); + + // Verify the update persisted + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('Modified outside transaction', $document1['body']['metadata']['data']); + + // List all documents to verify total count + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(2, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php b/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php new file mode 100644 index 0000000000..f6f217ab69 --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php @@ -0,0 +1,15 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + $this->assertEquals('vectorsdb', $database['body']['type']); + + return ['databaseId' => $database['body']['$id']]; + } + + #[Depends('testCreateCollectionSample')] + public function testCreateDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Build embedding vector matching collection dimensions (1536) + $vector = array_fill(0, 1536, 0.1); + $vector[0] = 1.0; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 1] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ] + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertNotEmpty($res['body']['$id']); + $documentId = $res['body']['$id']; + + // createdAt/updatedAt should be present and equal on initial create + $this->assertArrayHasKey('$createdAt', $res['body']); + $this->assertArrayHasKey('$updatedAt', $res['body']); + $this->assertNotEmpty($res['body']['$createdAt']); + $this->assertNotEmpty($res['body']['$updatedAt']); + $this->assertEquals($res['body']['$createdAt'], $res['body']['$updatedAt']); + + // Edge: invalid dimensions (vector too short) → expect 4xx + $badVec = [1.0, 0.0]; + $bad = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $badVec, + 'metadata' => ['type' => 'bad'] + ], + ]); + $this->assertGreaterThanOrEqual(400, $bad['headers']['status-code']); + $this->assertLessThan(500, $bad['headers']['status-code']); + + // Edge: invalid type values (strings) → expect 4xx + $strVec = ['1.0', '0.0', '0.0']; + $bad2 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $strVec, + 'metadata' => ['type' => 'bad-strings'] + ], + ]); + $this->assertGreaterThanOrEqual(400, $bad2['headers']['status-code']); + $this->assertLessThan(500, $bad2['headers']['status-code']); + + // Create another valid doc to verify list totals later + $res2 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 99] + ], + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $res2['headers']['status-code']); + $documentId2 = $res2['body']['$id']; + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => $documentId, + 'documentId2' => $documentId2, + 'createdAt' => $res['body']['$createdAt'], + 'updatedAt' => $res['body']['$updatedAt'], + ]; + } + + #[Depends('testCreateDocument')] + public function testGetDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($documentId, $res['body']['$id']); + + // Edge: missing document should return 404 + $missing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $missing['headers']['status-code']); + + return $data; + } + + #[Depends('testCreateDocument')] + public function testListDocuments(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::limit(5)->toString()] + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Pagination: limit 1, then offset 1 + $page1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::limit(1)->toString(), + Query::orderAsc('$id')->toString() + ] + ]); + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertEquals(1, \count($page1['body']['documents'] ?? [])); + + $page2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::limit(1)->toString(), + Query::offset(1)->toString(), + Query::orderAsc('$id')->toString() + ] + ]); + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertEquals(1, \count($page2['body']['documents'] ?? [])); + + return $data; + } + + #[Depends('testCreateDocument')] + public function testUpsertDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $vector = array_fill(0, 1536, 0.0); + // $vector[1] = 1.0; + + $upd = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 2] + ] + ]); + + $this->assertEquals(200, $upd['headers']['status-code']); + + // Verify update took effect + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals(2, $get['body']['metadata']['rank']); + // updatedAt should be greater or changed from earlier + $this->assertArrayHasKey('$updatedAt', $get['body']); + + return $data; + } + + #[Depends('testUpsertDocument')] + public function testUpdateDocument(array $data): array + { + // Upsert is used for update semantics + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $vector = array_fill(0, 1536, 0.0); + $vector[2] = 1.0; + + $upd = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 3] + ] + ]); + + $this->assertEquals(200, $upd['headers']['status-code']); + + // Re-update to check idempotence and metadata replacement + $vector2 = array_fill(0, 1536, 0.0); + $vector2[3] = 1.0; + $upd2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector2, + 'metadata' => ['type' => 'sample', 'rank' => 4] + ] + ]); + $this->assertEquals(200, $upd2['headers']['status-code']); + + // Verify updatedAt changed again + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertArrayHasKey('$updatedAt', $get2['body']); + + return $data; + } + + #[Depends('testUpdateDocument')] + public function testDocumentsVectorQueries(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create two more documents with distinct embeddings + $mk = function (array $vec, string $name) use ($databaseId, $collectionId) { + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vec, + 'metadata' => ['name' => $name] + ], + 'permissions' => [Permission::read(Role::any())] + ]); + }; + + $vA = array_fill(0, 1536, 0.0); + $vA[0] = 1.0; // close to [1,0,0,...] + $vB = array_fill(0, 1536, 0.0); + $vB[1] = 1.0; // close to [0,1,0,...] + + $mk($vA, 'A'); + $mk($vB, 'B'); + + // Dot product + $dot = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorDot('embeddings', $vA)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $dot['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $dot['body']['total']); + + // Cosine + $cos = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', $vB)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $cos['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $cos['body']['total']); + + // Euclidean + $eu = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorEuclidean('embeddings', $vA)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $eu['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $eu['body']['total']); + + // Combined vector + metadata filters + $combo = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', $vA)->toString(), + Query::notEqual('metadata', [['name' => 'B']])->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $combo['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $combo['body']['total']); + + // Ordering with $id ascending combined with vector + $ordered = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorDot('embeddings', $vA)->toString(), + Query::orderAsc('$id')->toString(), + Query::limit(3)->toString() + ] + ]); + $this->assertEquals(200, $ordered['headers']['status-code']); + + return $data; + } + + #[Depends('testDocumentsVectorQueries')] + public function testDeleteDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + + // GET after delete should be 404 + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + + // List should still work and reflect at least one less document compared to earlier pages (best-effort) + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::limit(5)->toString()] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + } + + #[Depends('testCreateCollectionSample')] + public function testDocumentPermissions(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create doc readable only by a specific user + $docId = ID::unique(); + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $docId, + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['scope' => 'private'] + ], + 'permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(201, $create['headers']['status-code']); + + $guest = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$docId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ]); + $this->assertEquals(404, $guest['headers']['status-code']); + + // GET with key should succeed regardless of document user-level permission + $withKey = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$docId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $withKey['headers']['status-code']); + } + + #[Depends('testCreateDatabase')] + public function testCreateCollection(array $data): array + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + $actors = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $actors['headers']['status-code']); + $this->assertEquals($actors['body']['name'], 'Actors'); + + return [ + 'databaseId' => $databaseId, + 'moviesId' => $movies['body']['$id'], + 'actorsId' => $actors['body']['$id'], + ]; + } + + public function testCreateDatabaseSample(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Sample VectorsDB' + ]); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Sample VectorsDB', $database['body']['name']); + $this->assertEquals('vectorsdb', $database['body']['type']); + + return ['databaseId' => $database['body']['$id']]; + } + + #[Depends('testCreateDatabaseSample')] + public function testCreateCollectionSample(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Sample Collection', + 'dimension' => 1536, + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $this->assertEquals('Sample Collection', $collection['body']['name']); + $this->assertEquals(1536, $collection['body']['dimension']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collection['body']['$id'], + ]; + } + + public function testCreateMultipleDatabasesWithCollections(): array + { + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $userId = $this->getUser()['$id']; + + /** + * Helper to create a database + */ + $createDatabase = function (string $name) use ($projectId, $apiKey) { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey + ], [ + 'databaseId' => ID::unique(), + 'name' => $name + ]); + + $this->assertEquals(201, $db['headers']['status-code']); + $this->assertEquals('vectorsdb', $db['body']['type']); + $this->assertEquals($name, $db['body']['name']); + $this->assertNotEmpty($db['body']['$id']); + + return $db['body']['$id']; + }; + + /** + * Helper to create a collection + */ + $createCollection = function (string $databaseId, string $name, int $dimensions = 1536) use ($projectId, $apiKey, $userId) { + $res = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey + ], [ + 'collectionId' => ID::unique(), + 'name' => $name, + 'documentSecurity' => true, + 'dimension' => $dimensions, + 'permissions' => [ + Permission::create(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertEquals($name, $res['body']['name']); + + return $res['body']['$id']; + }; + + /** + * === Database 1: MediaDB === + */ + $mediaDbId = $createDatabase('MediaDB'); + + $mediaCollections = ['Movies', 'Actors', 'Directors']; + $mediaCollectionIds = []; + + foreach ($mediaCollections as $col) { + $mediaCollectionIds[$col] = $createCollection($mediaDbId, $col); + } + + /** + * === Database 2: ContentDB === + */ + $contentDbId = $createDatabase('ContentDB'); + + $contentCollections = ['Articles', 'Authors']; + $contentCollectionIds = []; + + foreach ($contentCollections as $col) { + $contentCollectionIds[$col] = $createCollection($contentDbId, $col); + } + + // Create a tiny-dimension collection and insert a document to validate vector and object attributes + $tinyCollectionName = 'VectorsTiny'; + $tinyDimensions = 8; + $tinyCollectionId = $createCollection($mediaDbId, $tinyCollectionName, $tinyDimensions); + + return [ + 'databases' => [ + 'MediaDB' => [ + 'id' => $mediaDbId, + 'collections' => $mediaCollectionIds + ['VectorsTiny' => $tinyCollectionId], + ], + 'ContentDB' => [ + 'id' => $contentDbId, + 'collections' => $contentCollectionIds, + ], + ] + ]; + } + + public function testInvalidCollectionDimensions(): void + { + // dimensions = 0 -> expect 4xx + $bad0 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BadDims0' + ]); + $this->assertEquals(201, $bad0['headers']['status-code']); + $dbId = $bad0['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $dbId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'ZeroDims', + 'documentSecurity' => true, + 'dimension' => 0, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertGreaterThanOrEqual(400, $col['headers']['status-code']); + $this->assertLessThan(500, $col['headers']['status-code']); + + // dimensions too large -> expect 4xx + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $dbId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'HugeDims', + 'documentSecurity' => true, + 'dimension' => 16001, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertGreaterThanOrEqual(400, $col2['headers']['status-code']); + $this->assertLessThan(500, $col2['headers']['status-code']); + } + + public function testSingleDimensionVectorCollection(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'SingleDim' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'OneDim', + 'documentSecurity' => true, + 'dimension' => 1, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Create two docs with 1D embeddings + $id1 = ID::unique(); + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id1}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => ['embeddings' => [1.0]] + ]); + $id2 = ID::unique(); + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id2}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => ['embeddings' => [0.5]] + ]); + + // Query with vectorCosine + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::vectorCosine('embeddings', [1.0])->toString(), Query::limit(2)->toString()] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $res['body']['total']); + } + + public function testVectorInvalidValues(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'InvalidVals' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Docs', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $badPayloads = [ + ['embeddings' => [INF, 0.0, 0.0]], + ['embeddings' => [-INF, 0.0, 0.0]], + ['embeddings' => [NAN, 0.0, 0.0]], + ['embeddings' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0]], + ['embeddings' => [1.0, null, 0.0]], + ['embeddings' => [[1.0], [0.0], [0.0]]], + ['embeddings' => [true, false, true]], + ['embeddings' => [1.0, '2.0', 3.0]], + (function () { + $v = []; + $v[0] = 1.0; + $v[2] = 1.0; + return ['embeddings' => $v]; + })(), + ]; + + foreach ($badPayloads as $payload) { + $resp = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => $payload + ]); + $this->assertGreaterThanOrEqual(400, $resp['headers']['status-code']); + $this->assertLessThan(500, $resp['headers']['status-code']); + } + } + + public function testVectorAllZerosAndQuery(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'ZerosDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Zeros', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'data' => ['embeddings' => [0.0, 0.0, 0.0]] ]); + + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'data' => ['embeddings' => [1.0, 0.0, 0.0]] ]); + + $results = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertEquals(200, $results['headers']['status-code']); + $this->assertGreaterThan(0, $results['body']['total']); + } + + public function testVectorMultipleQueriesRejection(): void + { + // Create a simple DB and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'MultiQueryDB' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Two vector queries simultaneously should fail + $fail = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString(), + Query::vectorEuclidean('embeddings', [1.0, 0.0, 0.0])->toString() + ] + ]); + $this->assertGreaterThanOrEqual(400, $fail['headers']['status-code']); + $this->assertLessThan(500, $fail['headers']['status-code']); + } + + public function testVectorQueryOnNonVectorAttribute(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'NonVec' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Query on non-vector attribute 'metadata' should fail + $fail = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('metadata', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertGreaterThanOrEqual(400, $fail['headers']['status-code']); + $this->assertLessThan(500, $fail['headers']['status-code']); + } + + public function testVectorEmptyQueryCollection(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'EmptyQ' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals(0, $res['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testCreateIndexes(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['moviesId']; + + // HNSW Euclidean + $idxEuclidean = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxEuclidean['headers']['status-code']); + + // HNSW Dot (Inner Product) + $idxDot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxDot['headers']['status-code']); + + // HNSW Cosine + $idxCosine = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxCosine['headers']['status-code']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'indexes' => ['embedding_euclidean', 'embedding_dot', 'embedding_cosine'] + ]; + } + + #[Depends('testCreateIndexes')] + public function testListIndexes(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes'] ?? []); + foreach ($data['indexes'] as $expectedKey) { + $this->assertContains($expectedKey, $keys); + } + } + + #[Depends('testCreateIndexes')] + public function testGetIndexByKey(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $keysToTypes = [ + 'embedding_euclidean' => Database::INDEX_HNSW_EUCLIDEAN, + 'embedding_dot' => Database::INDEX_HNSW_DOT, + 'embedding_cosine' => Database::INDEX_HNSW_COSINE, + ]; + + foreach ($keysToTypes as $key => $type) { + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/{$key}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($key, $res['body']['key']); + $this->assertEquals($type, $res['body']['type']); + } + } + +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php new file mode 100644 index 0000000000..abe4d4968b --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php @@ -0,0 +1,312 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Vector Console DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Vector Console DB', $database['body']['name']); + $this->assertTrue($database['body']['enabled']); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + /** + * Test when database is disabled but can still create collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Vector Console DB Updated', + 'enabled' => false, + ]); + + $this->assertFalse($database['body']['enabled']); + + $tvShows = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'TvShows', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + /** + * Test when collection is disabled but can still modify collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $movies['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies', + 'enabled' => false, + ]); + + $this->assertEquals(201, $tvShows['headers']['status-code']); + $this->assertEquals($tvShows['body']['name'], 'TvShows'); + + return ['moviesId' => $movies['body']['$id'], 'databaseId' => $databaseId, 'tvShowsId' => $tvShows['body']['$id']]; + } + + #[Depends('testCreateCollection')] + public function testListCollection(array $data) + { + /** + * Test when database is disabled but can still call list collections + */ + $databaseId = $data['databaseId']; + + $collections = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $collections['headers']['status-code']); + $this->assertEquals(2, $collections['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testGetCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test when database and collection are disabled but can still call get collection + */ + $collection = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testUpdateCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test When database and collection are disabled but can still call update collection + */ + $collection = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies Updated', + 'enabled' => false + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies Updated', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testDeleteCollection(array $data) + { + $databaseId = $data['databaseId']; + $tvShowsId = $data['tvShowsId']; + + /** + * Test when database and collection are disabled but can still call delete collection + */ + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $tvShowsId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertEquals($response['body'], ""); + } + + #[Depends('testCreateCollection')] + public function testGetDatabaseUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(11, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsNumeric($response['body']['collectionsTotal']); + $this->assertIsArray($response['body']['collections']); + $this->assertIsArray($response['body']['documents']); + } + + + #[Depends('testCreateCollection')] + public function testGetCollectionUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/randomCollectionId/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(3, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsArray($response['body']['documents']); + } + + #[Depends('testCreateCollection')] + public function testGetCollectionLogs(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php new file mode 100644 index 0000000000..b27cb420b4 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php @@ -0,0 +1,205 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $databaseId = $database['body']['$id']; + + // Collection aliases write to create, update, delete + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ], + ]); + + $moviesId = $movies['body']['$id']; + + $this->assertContains(Permission::create(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + + // VectorsDB uses fixed schema (embeddings, metadata). No attribute creation needed. + + // Document aliases write to update, delete + $document1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertNotContains(Permission::create(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + + /** + * Test for FAILURE + */ + + // Document does not allow create permission + $document2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertEquals(400, $document2['headers']['status-code']); + } + + public function testUpdateWithoutPermission(): array + { + // As a part of preparation, we get ID of currently logged-in user + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + + $userId = $response['body']['$id']; + + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::custom('permissionCheckDatabase'), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + + $databaseId = $database['body']['$id']; + // Create collection + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::custom('permissionCheck'), + 'name' => 'permissionCheck', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Creating document by server, give read permission to our user + some other user + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => ID::custom('permissionCheckDocument'), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'AppwriteBeginner'], + ], + 'permissions' => [ + Permission::read(Role::user(ID::custom('user2'))), + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Update document + // This is the point of this test. We should be allowed to do this action, and it should not fail on permission check + $response = $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'AppwriteExpert'], + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Get name of the document, should be the new one + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("AppwriteExpert", $response['body']['metadata']['name']); + + // Cleanup to prevent collision with other tests + // Delete collection + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + + // Wait for database worker to finish deleting collection + sleep(2); + + // Make sure collection has been deleted + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + $this->assertEquals(404, $response['headers']['status-code']); + + return []; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php new file mode 100644 index 0000000000..9564b76079 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php @@ -0,0 +1,964 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('first'), + 'name' => 'Test 1', + ]); + $this->assertEquals(201, $db1['headers']['status-code']); + $this->assertEquals('Test 1', $db1['body']['name']); + $this->assertEquals('vectorsdb', $db1['body']['type']); + // Validate database response model fields on create + $this->assertArrayHasKey('$id', $db1['body']); + $this->assertArrayHasKey('$createdAt', $db1['body']); + $this->assertArrayHasKey('$updatedAt', $db1['body']); + $this->assertArrayHasKey('enabled', $db1['body']); + + $db2 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('second'), + 'name' => 'Test 2', + ]); + $this->assertEquals(201, $db2['headers']['status-code']); + $this->assertEquals('Test 2', $db2['body']['name']); + $this->assertEquals('vectorsdb', $db2['body']['type']); + + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['databases']); + $this->assertArrayHasKey('$id', $list['body']['databases'][0]); + $this->assertArrayHasKey('name', $list['body']['databases'][0]); + $this->assertArrayHasKey('type', $list['body']['databases'][0]); + + return ['databaseId' => $db1['body']['$id']]; + } + + #[Depends('testListDatabases')] + public function testGetDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($databaseId, $res['body']['$id']); + $this->assertEquals('Test 1', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testUpdateDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testDeleteDatabase(array $data): void + { + $databaseId = $data['databaseId']; + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + + public function testCollectionsCRUD(): array + { + // Create database for collections tests + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Collections DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create two collections + $col1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1', + 'collectionId' => ID::custom('first'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col1['headers']['status-code']); + // Validate collection response model on create + $this->assertArrayHasKey('$id', $col1['body']); + $this->assertArrayHasKey('$createdAt', $col1['body']); + $this->assertArrayHasKey('$updatedAt', $col1['body']); + $this->assertArrayHasKey('enabled', $col1['body']); + $this->assertArrayHasKey('documentSecurity', $col1['body']); + $this->assertArrayHasKey('dimension', $col1['body']); + + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 2', + 'collectionId' => ID::custom('second'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col2['headers']['status-code']); + $this->assertArrayHasKey('$id', $col2['body']); + $this->assertArrayHasKey('$createdAt', $col2['body']); + $this->assertArrayHasKey('$updatedAt', $col2['body']); + + // List collections + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['collections']); + $this->assertArrayHasKey('$id', $list['body']['collections'][0]); + $this->assertArrayHasKey('name', $list['body']['collections'][0]); + $this->assertArrayHasKey('dimension', $list['body']['collections'][0]); + + // Get collection + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($col1['body']['$id'], $get['body']['$id']); + $this->assertEquals('Test 1', $get['body']['name']); + $this->assertEquals(3, $get['body']['dimension']); + + // Update collection (name only) + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $upd['body']['name']); + $this->assertArrayHasKey('$updatedAt', $upd['body']); + + // Delete collection + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $col2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $col1['body']['$id'], + ]; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionMore(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Update collection name and dimensions + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Renamed', + 'dimension' => 4, + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $upd['body']['name']); + $this->assertEquals(4, $upd['body']['dimension']); + + // Read back to confirm + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $get['body']['name']); + $this->assertEquals(4, $get['body']['dimension']); + + return $data; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionEnabledFlag(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Disable collection + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + // Re-enable collection + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + return $data; + } + + public function testUpdateDatabaseNameAndEnabled(): void + { + // Create isolated database for this test to avoid ordering conflicts + $create = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Update DB', + ]); + $this->assertEquals(201, $create['headers']['status-code']); + $databaseId = $create['body']['$id']; + + // Update name + $rename = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + ]); + $this->assertEquals(200, $rename['headers']['status-code']); + $this->assertEquals('Test DB Renamed', $rename['body']['name']); + + // Toggle enabled off then on + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testRecreateIndex(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create a new index variant + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean_v2', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + // Ensure it exists + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean_v2', $get['body']['key']); + + // Delete it + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testIndexesCRUD(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create indexes + $eu = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $eu['headers']['status-code']); + + $dot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $dot['headers']['status-code']); + + $cos = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $cos['headers']['status-code']); + + // List indexes + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['indexes']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes']); + $this->assertContains('embedding_euclidean', $keys); + $this->assertContains('embedding_dot', $keys); + $this->assertContains('embedding_cosine', $keys); + + // Get index by key + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean', $get['body']['key']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $get['body']['type']); + + // Delete index + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + sleep(4); + // Ensure it's gone + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + } + + public function testBulkCreate(): array + { + // Setup: create isolated database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BulkDBCreate' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColCreate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['group' => 'bulkA'], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['group' => 'bulkB'], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + $this->assertNotEmpty($ids[0]); + $this->assertNotEmpty($ids[1]); + + // Fetch and validate persisted data via GET + foreach ($ids as $i => $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($id, $get['body']['$id']); + $this->assertIsArray($get['body']['embeddings']); + $this->assertCount(3, $get['body']['embeddings']); + $this->assertArrayHasKey('group', $get['body']['metadata']); + } + + return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'bulkIds' => $ids ]; + } + + public function testCreateTextEmbeddingsSuccessAndErrors(): void + { + // Setup new database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'EmbedDB', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'EmbedCol', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Success: two embeddings + $this->assertEventually(function () { + $ok = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'hello world', + 'second sentence', + ], + ]); + $this->assertEquals(200, $ok['headers']['status-code']); + $this->assertIsInt($ok['body']['total'] ?? 0); + $this->assertEquals(2, $ok['body']['total']); + $this->assertIsArray($ok['body']['embeddings']); + $this->assertCount(2, $ok['body']['embeddings']); + foreach ($ok['body']['embeddings'] as $embed) { + $this->assertIsString($embed['model']); + $this->assertIsInt($embed['dimension']); + $this->assertIsArray($embed['embedding']); + $this->assertGreaterThan(0, count($embed['embedding'])); + $this->assertArrayHasKey('error', $embed); + } + }, 3000, 100); + + // Error: missing texts payload + $missingTexts = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], []); + $this->assertEquals(400, $missingTexts['headers']['status-code']); + + // Error: invalid texts item type (must be strings) + $invalidItem = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'valid text', + 123, // invalid, not a string + ], + ]); + $this->assertEquals(400, $invalidItem['headers']['status-code']); + + // Error: unknown embedding model + $unknownModel = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'nonexistent-model', + 'texts' => ['hello'], + ]); + $this->assertEquals(400, $unknownModel['headers']['status-code']); + } + + public function testBulkUpsert(): void + { + // Setup fresh db/collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpsert' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpsert', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['group' => 'bulkA', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.2, 0.8, 0.0], + 'metadata' => ['group' => 'bulkB', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + $this->assertTrue($res['body']['documents'][0]['metadata']['updated']); + $this->assertTrue($res['body']['documents'][1]['metadata']['updated']); + + // Fetch and validate updated content + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['updated']); + } + + // Perform another bulk upsert to mutate the same documents + $docs2 = [ + [ 'embeddings' => [0.6, 0.4, 0.0], 'metadata' => ['updatedAgain' => true] ], + [ 'embeddings' => [0.3, 0.7, 0.0], 'metadata' => ['updatedAgain' => true] ], + ]; + $res2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs2 + ]); + $this->assertEquals(200, $res2['headers']['status-code']); + $this->assertIsArray($res2['body']['documents']); + $this->assertCount(2, $res2['body']['documents']); + + // Fetch again and assert second update persisted + $ids2 = array_map(fn ($d) => $d['$id'], $res2['body']['documents']); + foreach ($ids2 as $id) { + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertTrue($get2['body']['metadata']['updatedAgain']); + } + } + + public function testBulkUpdate(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpdate' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpdate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ 'metadata' => ['bulkUpdated' => true] ], + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + foreach ($res['body']['documents'] as $doc) { + $this->assertTrue($doc['metadata']['bulkUpdated']); + } + + // Fetch by IDs and assert update persisted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['bulkUpdated']); + } + } + + public function testBulkDelete(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBDelete' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColDelete', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + + // Ensure they are deleted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + } + + public function testCustomTimestamps(): void + { + // Setup: create database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'TimestampTestDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'TimestampTestCollection', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Test: Create document with custom timestamps using PUT (upsert) + $customCreatedAt = '1970-01-01T00:00:00.000+00:00'; + $customUpdatedAt = '1970-01-01T00:00:00.000+00:00'; + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $documentId = ID::unique(); + + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $documentId, + 'data' => [ + '$createdAt' => $customCreatedAt, + '$updatedAt' => $customUpdatedAt, + 'embeddings' => $vector, + 'metadata' => ['test' => 'custom_timestamps'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + $documentId = $doc['body']['$id']; + $this->assertNotEmpty($documentId); + + // Verify timestamps were set correctly + $this->assertEquals($customCreatedAt, $doc['body']['$createdAt'], 'CreatedAt should match custom timestamp'); + $this->assertEquals($customUpdatedAt, $doc['body']['$updatedAt'], 'UpdatedAt should match custom timestamp'); + + // Fetch document and verify timestamps persist + $fetched = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEquals($customCreatedAt, $fetched['body']['$createdAt'], 'CreatedAt should persist after fetch'); + $this->assertEquals($customUpdatedAt, $fetched['body']['$updatedAt'], 'UpdatedAt should persist after fetch'); + + // Test: Update document with new custom timestamps + $newCustomUpdatedAt = '2000-01-01T12:00:00.000+00:00'; + $vector2 = array_fill(0, 1536, 0.0); + $vector2[1] = 1.0; + + $updated = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + '$createdAt' => $customCreatedAt, // Keep original createdAt + '$updatedAt' => $newCustomUpdatedAt, // Update updatedAt + 'embeddings' => $vector2, + 'metadata' => ['test' => 'updated_timestamps'] + ] + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($customCreatedAt, $updated['body']['$createdAt'], 'CreatedAt should remain unchanged'); + $this->assertEquals($newCustomUpdatedAt, $updated['body']['$updatedAt'], 'UpdatedAt should be updated to new custom timestamp'); + + // Final verification + $final = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $final['headers']['status-code']); + $this->assertEquals($customCreatedAt, $final['body']['$createdAt'], 'CreatedAt should persist through updates'); + $this->assertEquals($newCustomUpdatedAt, $final['body']['$updatedAt'], 'UpdatedAt should reflect the latest custom timestamp'); + } + +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php new file mode 100644 index 0000000000..9335b7f55b --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php @@ -0,0 +1,280 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestDB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestDB', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $privateMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + $privateDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body']['documents'][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body']['documents'][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ] + ]); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['metadata']['title']); + + $privateDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestPermsWrite', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestPermsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + ], + 'documentSecurity' => true + ]); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + 'permissions' => [ + Permission::read(Role::any()), + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['metadata']['title']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php new file mode 100644 index 0000000000..cbc2add857 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php @@ -0,0 +1,196 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 2, 2, 2], + [[Permission::read(Role::user(ID::custom('random')))], 3, 3, 2], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 4, 4, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 5, 5, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 6, 6, 2], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 7, 7, 2], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 8, 8, 3], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 9, 9, 4], + [[Permission::read(Role::user(ID::custom('user1')))], 10, 10, 5], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 11, 11, 6], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 12, 12, 7], + ]; + } + + /** + * Setup database + * + * Data providers lose object state so explicitly pass [$users, $collections] to each iteration + * + * @return array + * @throws \Exception + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Private Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Document Only Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + return [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId + ]; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[DataProvider('permissionsProvider')] + #[Depends('testSetupDatabase')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount, $data) + { + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php new file mode 100644 index 0000000000..be1800b654 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php @@ -0,0 +1,87 @@ +client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => $id, + 'email' => $email, + 'password' => $password + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $session = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + $user = [ + '$id' => $user['body']['$id'], + 'email' => $user['body']['email'], + 'session' => $session, + ]; + $this->users[$id] = $user; + + return $user; + } + + public function getCreatedUser(string $id): array + { + return $this->users[$id] ?? []; + } + + public function createTeam(string $id, string $name): array + { + $team = $this->client->call(Client::METHOD_POST, '/teams', $this->getServerHeader(), [ + 'teamId' => $id, + 'name' => $name + ]); + $this->teams[$id] = $team['body']; + + return $team['body']; + } + + public function addToTeam(string $user, string $team, array $roles = []): array + { + $membership = $this->client->call(Client::METHOD_POST, '/teams/' . $team . '/memberships', $this->getServerHeader(), [ + 'teamId' => $team, + 'email' => $this->getCreatedUser($user)['email'], + 'roles' => $roles, + 'url' => 'http://localhost:5000/join-us#title' + ]); + + return [ + 'user' => $membership['body']['userId'], + 'membership' => $membership['body']['$id'] + ]; + } + + public function getServerHeader(): array + { + return [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php new file mode 100644 index 0000000000..4091ea7140 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php @@ -0,0 +1,199 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection1'), + 'name' => 'Collection 1', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ]); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection2'), + 'name' => 'Collection 2', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ] + ]); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database + * + * Data providers lose object state + * so explicitly pass $users to each iteration + * @return array $users + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection1'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection2'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + return $this->users; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[Depends('testSetupDatabase')] + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ]); + + if ($success) { + $this->assertCount(1, $documents['body']['documents']); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[Depends('testSetupDatabase')] + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php new file mode 100644 index 0000000000..aa8d87eb8e --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php @@ -0,0 +1,528 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'AtomicityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection for the test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'AtomicityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a document outside the transaction + $existingDocumentId = 'existing_doc'; + $doc1 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $existingDocumentId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'], + ], + ]); + + $this->assertEquals(201, $doc1['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed. Response: ' . json_encode($transaction)); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id. Response body: ' . json_encode($transaction['body'])); + $transactionId = $transaction['body']['$id']; + + // Add operations - second create reuses an existing documentId and should cause the commit to fail + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'txn_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'newuser@example.com'], + ], + [ + '$id' => $existingDocumentId, + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'duplicate@example.com'], + ], + [ + '$id' => 'txn_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'should-not-exist@example.com'], + ], + ], + 'transactionId' => $transactionId, + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Adding documents via normal route should succeed. Response: ' . json_encode($response['body'])); + + // Attempt to commit - should fail due to duplicate document ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test consistency - schema validation and constraints + */ + public function testConsistency(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConsistencyTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'ConsistencyTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $transactionId = $transaction['body']['$id']; + + // Stage operations with valid and invalid data (embedding length mismatch) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Valid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(2, 0.5), // Invalid dimensions + 'metadata' => ['name' => 'Invalid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.6), + 'metadata' => ['name' => 'Should Not Persist'], + ], + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Attempt to commit - should fail due to invalid embeddings + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertContains($response['headers']['status-code'], [400, 409, 500], 'Transaction commit should fail due to validation. Response: ' . json_encode($response['body'])); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test isolation - concurrent transactions on same data + */ + public function testIsolation(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsolationTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'IsolationTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document with status metadata + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['status' => 'pending'], + ], + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create first transaction + $transaction1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction1['headers']['status-code'], 'Transaction 1 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction1['body'], 'Transaction 1 response should have $id'); + $transactionId1 = $transaction1['body']['$id']; + + // Transaction 1: update status to approved + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'approved'], + ], + ], + ], + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + $this->assertEquals(200, $response1['headers']['status-code']); + + // Document should reflect the first transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('approved', $document['body']['metadata']['status']); + + // Create second transaction after first commit + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction2['headers']['status-code'], 'Transaction 2 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction2['body'], 'Transaction 2 response should have $id'); + $transactionId2 = $transaction2['body']['$id']; + + // Transaction 2: update status to declined + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'declined'], + ], + ], + ], + ]); + + // Commit second transaction and ensure isolation guarantees + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response2['headers']['status-code']); + + // Final document should reflect the second transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('declined', $document['body']['metadata']['status']); + } + + /** + * Test durability - committed data persists + */ + public function testDurability(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DurabilityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'DurabilityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction with multiple operations + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed'); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id'); + $transactionId = $transaction['body']['$id']; + + // Create two documents via normal route inside transaction + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'durable_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['data' => 'Important data 1'], + ], + [ + '$id' => 'durable_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['data' => 'Important data 2'], + ], + ], + 'transactionId' => $transactionId, + ]); + + // Update first document inside the same transaction + $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => ['data' => 'Updated important data 1'], + ], + 'transactionId' => $transactionId, + ]); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Commit should succeed. Response: ' . json_encode($response['body'])); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents exist and have correct data + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document1['headers']['status-code']); + $this->assertEquals('Updated important data 1', $document1['body']['metadata']['data']); + + $document2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_2", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document2['headers']['status-code']); + $this->assertEquals('Important data 2', $document2['body']['metadata']['data']); + + // Further update outside transaction to ensure persistence + $update = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'metadata' => ['data' => 'Modified outside transaction'], + ], + ]); + $this->assertEquals(200, $update['headers']['status-code']); + + // Verify the update persisted + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('Modified outside transaction', $document1['body']['metadata']['data']); + + // List all documents to verify total count + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(2, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php new file mode 100644 index 0000000000..70150a3bc8 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php @@ -0,0 +1,2371 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionTestDatabase' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Test creating a transaction with default TTL + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('status', $response['body']); + $this->assertArrayHasKey('operations', $response['body']); + $this->assertArrayHasKey('expiresAt', $response['body']); + $this->assertEquals('pending', $response['body']['status']); + $this->assertEquals(0, $response['body']['operations']); + + $transactionId1 = $response['body']['$id']; + + // Test creating a transaction with custom TTL + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 900 + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('pending', $response['body']['status']); + + $expiresAt = new \DateTime($response['body']['expiresAt']); + $now = new \DateTime(); + $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThan(800, $diff); + $this->assertLessThan(1000, $diff); + + $transactionId2 = $response['body']['$id']; + + // Test invalid TTL values + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 30 // Below minimum + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 4000 // Above maximum + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test adding operations to a transaction + */ + public function testCreateOperations(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionOperationsTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create a collection for testing + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionOperationsTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Add valid operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc1', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test Document 1'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc2', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Test Document 2'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['operations']); + + // Test adding more operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'doc1', + 'data' => [ + 'metadata' => ['name' => 'Updated Document 1'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(3, $response['body']['operations']); + + // Test invalid database ID + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => 'invalid_database', + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code'], 'Invalid database should return 404. Got: ' . json_encode($response['body'])); + + // Test invalid collection ID + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => 'invalid_collection', + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + /** + * Test committing a transaction + */ + public function testCommit(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionCommitTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionCommitTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Add operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc1', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test Document 1'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc2', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Test Document 2'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'doc1', + 'data' => [ + 'metadata' => ['name' => 'Updated Document 1'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(3, $response['body']['operations']); + + // Commit the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(2, $documents['body']['total']); + + // Verify the update was applied + $doc1Found = false; + foreach ($documents['body']['documents'] as $doc) { + if ($doc['$id'] === 'doc1') { + $this->assertEquals('Updated Document 1', $doc['metadata']['name']); + $doc1Found = true; + } + } + $this->assertTrue($doc1Found, 'Document doc1 should exist with updated name'); + + // Test committing already committed transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test rolling back a transaction + */ + public function testRollback(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionRollbackTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create a collection for rollback test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionRollbackTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Add operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'rollback_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['value' => 'Should not exist'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Rollback the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('failed', $response['body']['status']); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test transaction expiration + */ + public function testTransactionExpiration(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ExpirationTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction with minimum TTL (60 seconds) + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 60 + ]); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Add operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Should expire'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Verify transaction was created with correct expiration + $txnDetails = $this->client->call(Client::METHOD_GET, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $txnDetails['headers']['status-code']); + $this->assertEquals('pending', $txnDetails['body']['status']); + + // Verify expiration time is approximately 60 seconds from now + $expiresAt = new \DateTime($txnDetails['body']['expiresAt']); + $now = new \DateTime(); + $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThan(55, $diff); + $this->assertLessThan(65, $diff); + } + + /** + * Test maximum operations per transaction + */ + public function testTransactionSizeLimit(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'SizeLimitTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [Permission::create(Role::any())], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Try to add operations exceeding the limit (assuming limit is 100) + // We'll add 50 operations twice to test incremental limit + $operations = []; + for ($i = 0; $i < 50; $i++) { + $operations[] = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.001)), + 'metadata' => ['value' => 'Test ' . $i] + ] + ]; + } + + // First batch should succeed + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => $operations + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(50, $response['body']['operations']); + + // Second batch of 50 more operations + $operations = []; + for ($i = 50; $i < 100; $i++) { + $operations[] = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => 'doc_' . $i, + 'action' => 'create', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.001)), + 'metadata' => ['value' => 'Test ' . $i] + ] + ]; + } + + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => $operations + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(100, $response['body']['operations']); + + // Try to add one more operation - should fail + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc_overflow', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['value' => 'This should fail'] + ] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test concurrent transactions with conflicting operations + */ + public function testConcurrentTransactionConflicts(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConflictTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['counter' => 100] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create two transactions + $txn1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $txn2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId1 = $txn1['body']['$id']; + $transactionId2 = $txn2['body']['$id']; + + // Both transactions try to update the same document + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['counter' => 200] + ] + ] + ] + ]); + + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['counter' => 300] + ] + ] + ] + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response1['headers']['status-code']); + + // Commit second transaction - should fail with conflict + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response2['headers']['status-code']); // Conflict + + // Verify the document has the value from first transaction + $doc = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $doc['body']['metadata']['counter']); + } + + /** + * Test deleting a document that's being updated in a transaction + */ + public function testDeleteDocumentDuringTransaction(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DeleteConflictDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'target_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Original'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add update operation to transaction + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'target_doc', + 'data' => [ + 'metadata' => ['data' => 'Updated in transaction'] + ] + ] + ] + ]); + + // Delete the document outside of transaction + $response = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/target_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Try to commit transaction - should fail because document no longer exists + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Conflict + } + + /** + * Test bulk operations in transactions + */ + public function testBulkOperations(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkOpsDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create some initial documents + for ($i = 1; $i <= 5; $i++) { + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'existing_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.01)), + 'metadata' => [ + 'name' => 'Existing ' . $i, + 'category' => 'old' + ] + ] + ]); + } + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add bulk operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + // Bulk create + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkCreate', + 'data' => [ + [ + '$id' => 'bulk_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Bulk 1', 'category' => 'new'] + ], + [ + '$id' => 'bulk_2', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['name' => 'Bulk 2', 'category' => 'new'] + ], + [ + '$id' => 'bulk_3', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['name' => 'Bulk 3', 'category' => 'new'] + ], + ] + ], + // Bulk update + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkUpdate', + 'data' => [ + 'queries' => [Query::equal('metadata', [['category' => 'old']])->toString()], + 'data' => ['metadata' => ['category' => 'updated']] + ] + ], + // Bulk delete + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkDelete', + 'data' => [ + 'queries' => [Query::equal('$id', ['existing_5'])->toString()] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Verify results + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + // Should have 7 documents (5 existing - 1 deleted + 3 new) + $this->assertEquals(7, $documents['body']['total']); + + // Check categories were updated + $oldCategoryCount = 0; + $updatedCategoryCount = 0; + $newCategoryCount = 0; + + foreach ($documents['body']['documents'] as $doc) { + $category = $doc['metadata']['category'] ?? null; + switch ($category) { + case 'old': + $oldCategoryCount++; + break; + case 'updated': + $updatedCategoryCount++; + break; + case 'new': + $newCategoryCount++; + break; + } + } + + $this->assertEquals(0, $oldCategoryCount); + $this->assertEquals(4, $updatedCategoryCount); // 4 existing docs updated + $this->assertEquals(3, $newCategoryCount); // 3 new docs + } + + /** + * Test transaction with mixed success and failure operations + */ + public function testPartialFailureRollback(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'PartialFailureDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create HNSW index on embeddings + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'embeddings_index', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'], + ]); + + sleep(2); + + // Create an existing document + $duplicateId = ID::unique(); + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => $duplicateId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'] + ] + ]); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add operations - mix of valid and invalid (duplicate id) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'valid1@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'valid2@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => $duplicateId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'existing@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['email' => 'valid3@example.com'] + ] + ], + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Try to commit - should fail and rollback all operations + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); // Conflict due to duplicate + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); // Only the original document + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test double commit/rollback attempts + */ + public function testDoubleCommitRollback(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DoubleCommitDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [Permission::create(Role::any())], + ]); + + $collectionId = $collection['body']['$id']; + + // Test double commit + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add operation + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Test'] + ] + ] + ] + ]); + + // First commit + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Second commit attempt - should fail + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already committed + + // Test double rollback + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId2 = $transaction2['body']['$id']; + + // First rollback + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Second rollback attempt - should fail + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already rolled back + } + + /** + * Test operations on non-existent documents + */ + public function testOperationsOnNonExistentDocuments(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NonExistentDocDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Try to update non-existent document - should fail at staging time with early validation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'non_existent_doc', + 'data' => [ + 'metadata' => ['data' => 'Should fail'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Document not found at staging time + + // Test delete non-existent document - should also fail at staging time with early validation + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId2 = $transaction2['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'delete', + 'documentId' => 'non_existent_doc', + 'data' => [] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Document not found at staging time + } + + /** + * Test createDocument with transactionId via normal route + */ + public function testCreateDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'WriteRoutesTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create document via normal route with transactionId + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_from_route', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Created via normal route', + 'counter' => 100, + 'category' => 'test' + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Document should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_from_route", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_from_route", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Created via normal route', $response['body']['metadata']['name']); + } + + /** + * Test updateDocument with transactionId via normal route + */ + public function testUpdateDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'UpdateRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document outside transaction + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_to_update', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Original name', + 'counter' => 50, + 'category' => 'original' + ] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Update document via normal route with transactionId + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => [ + 'name' => 'Updated via normal route', + 'counter' => 150, + 'category' => 'updated' + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should still have original values outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('Original name', $response['body']['metadata']['name']); + $this->assertEquals(50, $response['body']['metadata']['counter']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now have updated values + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('Updated via normal route', $response['body']['metadata']['name']); + $this->assertEquals(150, $response['body']['metadata']['counter']); + } + + /** + * Test upsertDocument with transactionId via normal route + */ + public function testUpsertDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'UpsertRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Upsert document (create) via normal route with transactionId + $response = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_upsert', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Created by upsert', + 'counter' => 25 + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Document should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Upsert same document (update) in same transaction + $response = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_upsert', + 'data' => [ + 'metadata' => [ + 'name' => 'Updated by upsert', + 'counter' => 75 + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); // Upsert in transaction returns 201 + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now exist with updated values + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Updated by upsert', $response['body']['metadata']['name']); + $this->assertEquals(75, $response['body']['metadata']['counter']); + } + + /** + * Test deleteDocument with transactionId via normal route + */ + public function testDeleteDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DeleteRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document outside transaction + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_to_delete', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Will be deleted'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Delete document via normal route with transactionId + $response = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'transactionId' => $transactionId + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Document should still exist outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should no longer exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + /** + * Test bulkCreate with transactionId via normal route + */ + public function testBulkCreate(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkCreateTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Bulk create via normal route with transactionId + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'bulk_create_1', + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Bulk created 1', + 'category' => 'bulk_created' + ] + ], + [ + '$id' => 'bulk_create_2', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => [ + 'name' => 'Bulk created 2', + 'category' => 'bulk_created' + ] + ], + [ + '$id' => 'bulk_create_3', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => [ + 'name' => 'Bulk created 3', + 'category' => 'bulk_created' + ] + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); // Bulk operations return 200 + + // Documents should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', [['metadata' => ['category' => 'bulk_created']]])->toString()] + ]); + + $this->assertEquals(0, $response['body']['total']); + + // Individual document check + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/bulk_create_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should now exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_created']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + + // Verify individual documents + for ($i = 1; $i <= 3; $i++) { + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/bulk_create_{$i}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("Bulk created {$i}", $response['body']['metadata']['name']); + $this->assertEquals('bulk_created', $response['body']['metadata']['category']); + } + } + + /** + * Test bulkUpdate with transactionId via normal route + */ + public function testBulkUpdate(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkUpdateTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create documents for bulk testing + for ($i = 1; $i <= 3; $i++) { + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'bulk_update_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 * $i), + 'metadata' => [ + 'name' => 'Bulk doc ' . $i, + 'category' => 'bulk_test' + ] + ] + ]); + } + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Bulk update via normal route with transactionId + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_test']])->toString()], + 'data' => ['metadata' => ['category' => 'bulk_updated']], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should still have original category outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_test']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should now have updated category + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_updated']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + } + + /** + * Test bulkUpsert with transactionId via normal route + */ + public function testBulkUpsert(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkUpsertTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Invalid action type + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'invalidAction', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 2: Missing required action field + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 3: Missing required databaseId field + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 4: Missing documentId for create operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 5: Missing data for create operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique() + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 6: BulkCreate with non-array data + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'bulkCreate', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => 'not an array' + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 7: BulkUpdate with missing queries + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'bulkUpdate', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => [ + 'data' => ['name' => 'Updated'] + ] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 8: Empty operations array + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 9: Operations not an array + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => 'not an array' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test validation for committing/rolling back transactions + */ + public function testCommitRollbackValidation(): void + { + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Missing both commit and rollback + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), []); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 2: Both commit and rollback set to true + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true, + 'rollback' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 3: Invalid transaction ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/invalid_id", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Test 4: Attempt to commit already committed transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test validation for non-existent resources + */ + public function testNonExistentResources(): void + { + // Create database and transaction + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ResourceTestDatabase' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Non-existent database + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => 'nonExistentDatabase', + 'collectionId' => 'someCollection', + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Test 2: Non-existent collection + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => 'nonExistentCollection', + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php new file mode 100644 index 0000000000..40ff27c572 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Vector Console DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Vector Console DB', $database['body']['name']); + $this->assertTrue($database['body']['enabled']); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + /** + * Test when database is disabled but can still create collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Vector Console DB Updated', + 'enabled' => false, + ]); + + $this->assertFalse($database['body']['enabled']); + + $tvShows = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'TvShows', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + /** + * Test when collection is disabled but can still modify collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $movies['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies', + 'enabled' => false, + ]); + + $this->assertEquals(201, $tvShows['headers']['status-code']); + $this->assertEquals($tvShows['body']['name'], 'TvShows'); + + return ['moviesId' => $movies['body']['$id'], 'databaseId' => $databaseId, 'tvShowsId' => $tvShows['body']['$id']]; + } + + #[Depends('testCreateCollection')] + public function testListCollection(array $data) + { + /** + * Test when database is disabled but can still call list collections + */ + $databaseId = $data['databaseId']; + + $collections = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $collections['headers']['status-code']); + $this->assertEquals(2, $collections['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testGetCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test when database and collection are disabled but can still call get collection + */ + $collection = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testUpdateCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test When database and collection are disabled but can still call update collection + */ + $collection = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies Updated', + 'enabled' => false + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies Updated', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testDeleteCollection(array $data) + { + $databaseId = $data['databaseId']; + $tvShowsId = $data['tvShowsId']; + + /** + * Test when database and collection are disabled but can still call delete collection + */ + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $tvShowsId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertEquals($response['body'], ""); + } + + #[Depends('testCreateCollection')] + public function testGetDatabaseUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(11, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsNumeric($response['body']['collectionsTotal']); + $this->assertIsArray($response['body']['collections']); + $this->assertIsArray($response['body']['documents']); + } + + + #[Depends('testCreateCollection')] + public function testGetCollectionUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/randomCollectionId/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(3, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsArray($response['body']['documents']); + } + + #[Depends('testCreateCollection')] + public function testGetCollectionLogs(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php new file mode 100644 index 0000000000..7add5c7f71 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php @@ -0,0 +1,205 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $databaseId = $database['body']['$id']; + + // Collection aliases write to create, update, delete + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ], + ]); + + $moviesId = $movies['body']['$id']; + + $this->assertContains(Permission::create(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + + // VectorsDB uses fixed schema (embeddings, metadata). No attribute creation needed. + + // Document aliases write to update, delete + $document1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertNotContains(Permission::create(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + + /** + * Test for FAILURE + */ + + // Document does not allow create permission + $document2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertEquals(400, $document2['headers']['status-code']); + } + + public function testUpdateWithoutPermission(): array + { + // As a part of preparation, we get ID of currently logged-in user + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + + $userId = $response['body']['$id']; + + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::custom('permissionCheckDatabase'), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + + $databaseId = $database['body']['$id']; + // Create collection + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::custom('permissionCheck'), + 'name' => 'permissionCheck', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Creating document by server, give read permission to our user + some other user + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => ID::custom('permissionCheckDocument'), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'AppwriteBeginner'], + ], + 'permissions' => [ + Permission::read(Role::user(ID::custom('user2'))), + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Update document + // This is the point of this test. We should be allowed to do this action, and it should not fail on permission check + $response = $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'AppwriteExpert'], + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Get name of the document, should be the new one + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("AppwriteExpert", $response['body']['metadata']['name']); + + // Cleanup to prevent collision with other tests + // Delete collection + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + + // Wait for database worker to finish deleting collection + sleep(2); + + // Make sure collection has been deleted + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + $this->assertEquals(404, $response['headers']['status-code']); + + return []; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php b/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php new file mode 100644 index 0000000000..ceb672443e --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php @@ -0,0 +1,963 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('first'), + 'name' => 'Test 1', + ]); + $this->assertEquals(201, $db1['headers']['status-code']); + $this->assertEquals('Test 1', $db1['body']['name']); + $this->assertEquals('vectorsdb', $db1['body']['type']); + // Validate database response model fields on create + $this->assertArrayHasKey('$id', $db1['body']); + $this->assertArrayHasKey('$createdAt', $db1['body']); + $this->assertArrayHasKey('$updatedAt', $db1['body']); + $this->assertArrayHasKey('enabled', $db1['body']); + + $db2 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('second'), + 'name' => 'Test 2', + ]); + $this->assertEquals(201, $db2['headers']['status-code']); + $this->assertEquals('Test 2', $db2['body']['name']); + $this->assertEquals('vectorsdb', $db2['body']['type']); + + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['databases']); + $this->assertArrayHasKey('$id', $list['body']['databases'][0]); + $this->assertArrayHasKey('name', $list['body']['databases'][0]); + $this->assertArrayHasKey('type', $list['body']['databases'][0]); + + return ['databaseId' => $db1['body']['$id']]; + } + + #[Depends('testListDatabases')] + public function testGetDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($databaseId, $res['body']['$id']); + $this->assertEquals('Test 1', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testUpdateDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testDeleteDatabase(array $data): void + { + $databaseId = $data['databaseId']; + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + + public function testCollectionsCRUD(): array + { + // Create database for collections tests + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Collections DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create two collections + $col1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1', + 'collectionId' => ID::custom('first'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col1['headers']['status-code']); + // Validate collection response model on create + $this->assertArrayHasKey('$id', $col1['body']); + $this->assertArrayHasKey('$createdAt', $col1['body']); + $this->assertArrayHasKey('$updatedAt', $col1['body']); + $this->assertArrayHasKey('enabled', $col1['body']); + $this->assertArrayHasKey('documentSecurity', $col1['body']); + $this->assertArrayHasKey('dimension', $col1['body']); + + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 2', + 'collectionId' => ID::custom('second'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col2['headers']['status-code']); + $this->assertArrayHasKey('$id', $col2['body']); + $this->assertArrayHasKey('$createdAt', $col2['body']); + $this->assertArrayHasKey('$updatedAt', $col2['body']); + + // List collections + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['collections']); + $this->assertArrayHasKey('$id', $list['body']['collections'][0]); + $this->assertArrayHasKey('name', $list['body']['collections'][0]); + $this->assertArrayHasKey('dimension', $list['body']['collections'][0]); + + // Get collection + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($col1['body']['$id'], $get['body']['$id']); + $this->assertEquals('Test 1', $get['body']['name']); + $this->assertEquals(3, $get['body']['dimension']); + + // Update collection (name only) + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $upd['body']['name']); + $this->assertArrayHasKey('$updatedAt', $upd['body']); + + // Delete collection + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $col2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $col1['body']['$id'], + ]; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionMore(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Update collection name and dimensions + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Renamed', + 'dimension' => 4, + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $upd['body']['name']); + $this->assertEquals(4, $upd['body']['dimension']); + + // Read back to confirm + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $get['body']['name']); + $this->assertEquals(4, $get['body']['dimension']); + + return $data; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionEnabledFlag(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Disable collection + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + // Re-enable collection + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + return $data; + } + + public function testUpdateDatabaseNameAndEnabled(): void + { + // Create isolated database for this test to avoid ordering conflicts + $create = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Update DB', + ]); + $this->assertEquals(201, $create['headers']['status-code']); + $databaseId = $create['body']['$id']; + + // Update name + $rename = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + ]); + $this->assertEquals(200, $rename['headers']['status-code']); + $this->assertEquals('Test DB Renamed', $rename['body']['name']); + + // Toggle enabled off then on + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testRecreateIndex(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create a new index variant + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean_v2', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + // Ensure it exists + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean_v2', $get['body']['key']); + + // Delete it + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testIndexesCRUD(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create indexes + $eu = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $eu['headers']['status-code']); + + $dot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $dot['headers']['status-code']); + + $cos = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $cos['headers']['status-code']); + + // List indexes + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['indexes']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes']); + $this->assertContains('embedding_euclidean', $keys); + $this->assertContains('embedding_dot', $keys); + $this->assertContains('embedding_cosine', $keys); + + // Get index by key + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean', $get['body']['key']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $get['body']['type']); + + // Delete index + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + sleep(4); + // Ensure it's gone + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + } + + public function testBulkCreate(): array + { + // Setup: create isolated database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BulkDBCreate' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColCreate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['group' => 'bulkA'], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['group' => 'bulkB'], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + $this->assertNotEmpty($ids[0]); + $this->assertNotEmpty($ids[1]); + + // Fetch and validate persisted data via GET + foreach ($ids as $i => $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($id, $get['body']['$id']); + $this->assertIsArray($get['body']['embeddings']); + $this->assertCount(3, $get['body']['embeddings']); + $this->assertArrayHasKey('group', $get['body']['metadata']); + } + + return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'bulkIds' => $ids ]; + } + + public function testCreateTextEmbeddingsSuccessAndErrors(): void + { + // Setup new database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'EmbedDB', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'EmbedCol', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Success: two embeddings + $this->assertEventually(function () { + $ok = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'hello world', + 'second sentence', + ], + ]); + $this->assertEquals(200, $ok['headers']['status-code']); + $this->assertIsInt($ok['body']['total'] ?? 0); + $this->assertEquals(2, $ok['body']['total']); + $this->assertIsArray($ok['body']['embeddings']); + $this->assertCount(2, $ok['body']['embeddings']); + foreach ($ok['body']['embeddings'] as $embed) { + $this->assertIsString($embed['model']); + $this->assertIsInt($embed['dimension']); + $this->assertIsArray($embed['embedding']); + $this->assertGreaterThan(0, count($embed['embedding'])); + $this->assertArrayHasKey('error', $embed); + } + }, 3000, 100); + + // Error: missing texts payload + $missingTexts = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], []); + $this->assertEquals(400, $missingTexts['headers']['status-code']); + + // Error: invalid texts item type (must be strings) + $invalidItem = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'valid text', + 123, // invalid, not a string + ], + ]); + $this->assertEquals(400, $invalidItem['headers']['status-code']); + + // Error: unknown embedding model + $unknownModel = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'nonexistent-model', + 'texts' => ['hello'], + ]); + $this->assertEquals(400, $unknownModel['headers']['status-code']); + } + + public function testBulkUpsert(): void + { + // Setup fresh db/collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpsert' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpsert', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['group' => 'bulkA', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.2, 0.8, 0.0], + 'metadata' => ['group' => 'bulkB', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + $this->assertTrue($res['body']['documents'][0]['metadata']['updated']); + $this->assertTrue($res['body']['documents'][1]['metadata']['updated']); + + // Fetch and validate updated content + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['updated']); + } + + // Perform another bulk upsert to mutate the same documents + $docs2 = [ + [ 'embeddings' => [0.6, 0.4, 0.0], 'metadata' => ['updatedAgain' => true] ], + [ 'embeddings' => [0.3, 0.7, 0.0], 'metadata' => ['updatedAgain' => true] ], + ]; + $res2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs2 + ]); + $this->assertEquals(200, $res2['headers']['status-code']); + $this->assertIsArray($res2['body']['documents']); + $this->assertCount(2, $res2['body']['documents']); + + // Fetch again and assert second update persisted + $ids2 = array_map(fn ($d) => $d['$id'], $res2['body']['documents']); + foreach ($ids2 as $id) { + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertTrue($get2['body']['metadata']['updatedAgain']); + } + } + + public function testBulkUpdate(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpdate' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpdate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ 'metadata' => ['bulkUpdated' => true] ], + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + foreach ($res['body']['documents'] as $doc) { + $this->assertTrue($doc['metadata']['bulkUpdated']); + } + + // Fetch by IDs and assert update persisted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['bulkUpdated']); + } + } + + public function testBulkDelete(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBDelete' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColDelete', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + + // Ensure they are deleted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + } + + public function testCustomTimestamps(): void + { + // Setup: create database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'TimestampTestDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'TimestampTestCollection', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Test: Create document with custom timestamps using PUT (upsert) + $customCreatedAt = '1970-01-01T00:00:00.000+00:00'; + $customUpdatedAt = '1970-01-01T00:00:00.000+00:00'; + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $documentId = ID::unique(); + + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $documentId, + 'data' => [ + '$createdAt' => $customCreatedAt, + '$updatedAt' => $customUpdatedAt, + 'embeddings' => $vector, + 'metadata' => ['test' => 'custom_timestamps'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + $documentId = $doc['body']['$id']; + $this->assertNotEmpty($documentId); + + // Verify timestamps were set correctly + $this->assertEquals($customCreatedAt, $doc['body']['$createdAt'], 'CreatedAt should match custom timestamp'); + $this->assertEquals($customUpdatedAt, $doc['body']['$updatedAt'], 'UpdatedAt should match custom timestamp'); + + // Fetch document and verify timestamps persist + $fetched = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEquals($customCreatedAt, $fetched['body']['$createdAt'], 'CreatedAt should persist after fetch'); + $this->assertEquals($customUpdatedAt, $fetched['body']['$updatedAt'], 'UpdatedAt should persist after fetch'); + + // Test: Update document with new custom timestamps + $newCustomUpdatedAt = '2000-01-01T12:00:00.000+00:00'; + $vector2 = array_fill(0, 1536, 0.0); + $vector2[1] = 1.0; + + $updated = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + '$createdAt' => $customCreatedAt, // Keep original createdAt + '$updatedAt' => $newCustomUpdatedAt, // Update updatedAt + 'embeddings' => $vector2, + 'metadata' => ['test' => 'updated_timestamps'] + ] + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($customCreatedAt, $updated['body']['$createdAt'], 'CreatedAt should remain unchanged'); + $this->assertEquals($newCustomUpdatedAt, $updated['body']['$updatedAt'], 'UpdatedAt should be updated to new custom timestamp'); + + // Final verification + $final = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $final['headers']['status-code']); + $this->assertEquals($customCreatedAt, $final['body']['$createdAt'], 'CreatedAt should persist through updates'); + $this->assertEquals($newCustomUpdatedAt, $final['body']['$updatedAt'], 'UpdatedAt should reflect the latest custom timestamp'); + } + +} diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php index 168e0561e2..cc18d14a8e 100644 --- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\Console; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -653,4 +654,52 @@ class FunctionsConsoleClientTest extends Scope $this->cleanupFunction($functionId); } + + public function testFunctionDeploymentRetentionWithMaintenance(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'deploymentRetention' => 180 + ]); + $this->assertNotEmpty($functionId); + + $deploymentIdInactive = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactive); + + $deploymentIdInactiveOld = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactiveOld); + + $deploymentIdActive = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdActive); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); + $this->assertSame(0, $code, "Time-travel command failed with code $code: $stderr ($stdout)"); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite maintenance --type=trigger", '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + $this->assertEventually(function () use ($functionId) { + $response = $this->listDeployments($functionId); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + }); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 53844fe2c8..e0002fdafb 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -196,25 +196,33 @@ class FunctionsCustomServerTest extends Scope { $specifications = $this->listSpecifications(); $this->assertEquals(200, $specifications['headers']['status-code']); - $this->assertGreaterThan(0, $specifications['body']['total']); + $this->assertGreaterThanOrEqual(2, $specifications['body']['total']); $this->assertArrayHasKey(0, $specifications['body']['specifications']); + $this->assertArrayHasKey(1, $specifications['body']['specifications']); $this->assertArrayHasKey('memory', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('cpus', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('enabled', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('slug', $specifications['body']['specifications'][0]); + $this->assertArrayHasKey('memory', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('cpus', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('enabled', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('slug', $specifications['body']['specifications'][1]); $function = $this->createFunction([ 'functionId' => ID::unique(), 'name' => 'Specs function', 'runtime' => 'node-22', - 'specification' => $specifications['body']['specifications'][0]['slug'] + 'buildSpecification' => $specifications['body']['specifications'][0]['slug'], + 'runtimeSpecification' => $specifications['body']['specifications'][1]['slug'], ]); $this->assertEquals(201, $function['headers']['status-code']); - $this->assertEquals($specifications['body']['specifications'][0]['slug'], $function['body']['specification']); + $this->assertEquals($specifications['body']['specifications'][0]['slug'], $function['body']['buildSpecification']); + $this->assertEquals($specifications['body']['specifications'][1]['slug'], $function['body']['runtimeSpecification']); $function = $this->getFunction($function['body']['$id']); $this->assertEquals(200, $function['headers']['status-code']); - $this->assertEquals($specifications['body']['specifications'][0]['slug'], $function['body']['specification']); + $this->assertEquals($specifications['body']['specifications'][0]['slug'], $function['body']['buildSpecification']); + $this->assertEquals($specifications['body']['specifications'][1]['slug'], $function['body']['runtimeSpecification']); $this->cleanupFunction($function['body']['$id']); @@ -222,7 +230,15 @@ class FunctionsCustomServerTest extends Scope 'functionId' => ID::unique(), 'name' => 'Specs function', 'runtime' => 'node-22', - 'specification' => 'cheap-please' + 'buildSpecification' => 'cheap-please' + ]); + $this->assertEquals(400, $function['headers']['status-code']); + + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Specs function', + 'runtime' => 'node-22', + 'runtimeSpecification' => 'cheap-please' ]); $this->assertEquals(400, $function['headers']['status-code']); } @@ -1552,12 +1568,12 @@ class FunctionsCustomServerTest extends Scope 'timeout' => 15, 'runtime' => 'node-22', 'entrypoint' => 'index.js', - 'specification' => Specification::S_1VCPU_1GB, + 'runtimeSpecification' => Specification::S_1VCPU_1GB, ]); $this->assertEquals(200, $function['headers']['status-code']); $this->assertNotEmpty($function['body']['$id']); - $this->assertEquals(Specification::S_1VCPU_1GB, $function['body']['specification']); + $this->assertEquals(Specification::S_1VCPU_1GB, $function['body']['runtimeSpecification']); // Verify the updated specs $execution = $this->createExecution($functionId); @@ -1586,12 +1602,12 @@ class FunctionsCustomServerTest extends Scope 'timeout' => 15, 'runtime' => 'node-22', 'entrypoint' => 'index.js', - 'specification' => Specification::S_1VCPU_512MB, + 'runtimeSpecification' => Specification::S_1VCPU_512MB, ]); $this->assertEquals(200, $function['headers']['status-code']); $this->assertNotEmpty($function['body']['$id']); - $this->assertEquals(Specification::S_1VCPU_512MB, $function['body']['specification']); + $this->assertEquals(Specification::S_1VCPU_512MB, $function['body']['runtimeSpecification']); // Verify the updated specs $execution = $this->createExecution($functionId); @@ -1616,11 +1632,29 @@ class FunctionsCustomServerTest extends Scope 'timeout' => 15, 'runtime' => 'node-22', 'entrypoint' => 'index.js', - 'specification' => 's-2vcpu-512mb', // Invalid specification + 'buildSpecification' => 's-2vcpu-512mb', // Invalid specification ]); $this->assertEquals(400, $function['headers']['status-code']); - $this->assertStringStartsWith('Invalid `specification` param: Specification must be one of:', $function['body']['message']); + $this->assertStringStartsWith('Invalid `buildSpecification` param: Specification must be one of:', $function['body']['message']); + + $function = $this->client->call(Client::METHOD_PUT, '/functions/' . $data['functionId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Test1', + 'events' => [ + 'users.*.update.name', + 'users.*.update.email', + ], + 'timeout' => 15, + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'runtimeSpecification' => 's-2vcpu-512mb', // Invalid specification + ]); + + $this->assertEquals(400, $function['headers']['status-code']); + $this->assertStringStartsWith('Invalid `runtimeSpecification` param: Specification must be one of:', $function['body']['message']); } public function testDeleteDeployment(): void @@ -2221,6 +2255,8 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertArrayNotHasKey('scopes', $response['body']); $this->assertArrayNotHasKey('specification', $response['body']); + $this->assertArrayNotHasKey('buildSpecification', $response['body']); + $this->assertArrayNotHasKey('runtimeSpecification', $response['body']); // get function with 1.5.0 response format header $function = $this->client->call(Client::METHOD_GET, '/functions/' . $response['body']['$id'], array_merge([ @@ -2231,13 +2267,31 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(200, $function['headers']['status-code']); $this->assertArrayNotHasKey('scopes', $function['body']); + $this->assertArrayNotHasKey('buildSpecification', $function['body']); + $this->assertArrayNotHasKey('runtimeSpecification', $function['body']); $this->assertArrayNotHasKey('specification', $function['body']); - $function = $this->getFunction($function['body']['$id']); + // get function with 1.8.0 response format header + $function = $this->client->call(Client::METHOD_GET, '/functions/' . $response['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', // add response format header + ], $this->getHeaders())); $this->assertEquals(200, $function['headers']['status-code']); $this->assertArrayHasKey('scopes', $function['body']); $this->assertArrayHasKey('specification', $function['body']); + $this->assertArrayNotHasKey('buildSpecification', $function['body']); + $this->assertArrayNotHasKey('runtimeSpecification', $function['body']); + + // get function with latest version + $function = $this->getFunction($function['body']['$id']); + + $this->assertEquals(200, $function['headers']['status-code']); + $this->assertArrayHasKey('scopes', $function['body']); + $this->assertArrayNotHasKey('specification', $function['body']); + $this->assertArrayHasKey('buildSpecification', $function['body']); + $this->assertArrayHasKey('runtimeSpecification', $function['body']); $functionId = $function['body']['$id'] ?? ''; $this->cleanupFunction($functionId); @@ -2387,15 +2441,17 @@ class FunctionsCustomServerTest extends Scope 'entrypoint' => 'index.js', 'logging' => false, 'execute' => ['any'], - 'specification' => Specification::S_2VCPU_2GB, + 'buildSpecification' => Specification::S_2VCPU_2GB, + 'runtimeSpecification' => Specification::S_1VCPU_1GB, 'commands' => 'echo $APPWRITE_FUNCTION_MEMORY:$APPWRITE_FUNCTION_CPUS', ]); $this->assertEquals(201, $function['headers']['status-code']); - $this->assertEquals(Specification::S_2VCPU_2GB, $function['body']['specification']); + $this->assertEquals(Specification::S_2VCPU_2GB, $function['body']['buildSpecification']); + $this->assertEquals(Specification::S_1VCPU_1GB, $function['body']['runtimeSpecification']); $this->assertNotEmpty($function['body']['$id']); - $functionId = $functionId = $function['body']['$id'] ?? ''; + $functionId = $function['body']['$id'] ?? ''; $deploymentId = $this->setupDeployment($functionId, [ 'code' => $this->packageFunction('basic'), @@ -2414,8 +2470,8 @@ class FunctionsCustomServerTest extends Scope $this->assertNotEmpty($execution['body']['$id']); $executionResponse = json_decode($execution['body']['responseBody'], true); - $this->assertEquals('2048', $executionResponse['APPWRITE_FUNCTION_MEMORY']); - $this->assertEquals('2', $executionResponse['APPWRITE_FUNCTION_CPUS']); + $this->assertEquals('1024', $executionResponse['APPWRITE_FUNCTION_MEMORY']); + $this->assertEquals('1', $executionResponse['APPWRITE_FUNCTION_CPUS']); $this->cleanupFunction($functionId); } @@ -2771,7 +2827,6 @@ class FunctionsCustomServerTest extends Scope $this->assertLessThanOrEqual(APP_FUNCTION_LOG_LENGTH_LIMIT, strlen($logs)); $this->assertStringStartsWith('[WARNING] Logs truncated', $logs); - $this->assertStringNotContainsString('z', $logs); $this->assertStringContainsString('a', $logs); // Verify errors are truncated and warning message is present at the beginning @@ -2779,9 +2834,123 @@ class FunctionsCustomServerTest extends Scope $this->assertLessThanOrEqual(APP_FUNCTION_ERROR_LENGTH_LIMIT, strlen($errors)); $this->assertStringStartsWith('[WARNING] Errors truncated', $errors); - $this->assertStringNotContainsString('z', $errors); $this->assertStringContainsString('a', $errors); $this->cleanupFunction($functionId); } + + public function testFunctionDeploymentRetention(): void + { + $functionIds = []; + + // Default + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Success values + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 0 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + + // Failure values + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + // Update flow + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + $functionIdForUpdate = $response['body']['$id']; + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => 90 + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->getFunction($functionIdForUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->getFunction($functionIdForUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Failed update flow + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + foreach ($functionIds as $functionId) { + $this->cleanupFunction($functionId); + } + } } diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index e8e533d390..569600c01d 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -3,11 +3,13 @@ namespace Tests\E2E\Services\Migrations; use CURLFile; +use PHPUnit\Framework\Attributes\Depends; use Tests\E2E\Client; use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Console; +use Utopia\Database\Database; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -186,6 +188,26 @@ trait MigrationsBase return $migrationResult; } + /** + * Get migration status by ID (without creating a new migration) + * + * @param string $migrationId + * @return array + */ + public function getMigrationStatus(string $migrationId): array + { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + return $response['body']; + } + /** * Appwrite E2E Migration Tests */ @@ -1799,4 +1821,2109 @@ trait MigrationsBase 'x-appwrite-key' => $this->getProject()['apiKey'] ]); } + + /** + * Messaging + */ + public function testAppwriteMigrationMessagingProvider(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid', + 'apiKey' => 'my-apikey', + 'from' => 'migration@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $this->assertNotEmpty($provider['body']['$id']); + + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_PROVIDER], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + '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($providerId, $response['body']['$id']); + $this->assertEquals('Migration Sendgrid', $response['body']['name']); + $this->assertEquals('email', $response['body']['type']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingProviderSMTP(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/smtp', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration SMTP', + 'host' => 'smtp.test.com', + 'port' => 587, + 'from' => 'migration-smtp@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + '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($providerId, $response['body']['$id']); + $this->assertEquals('Migration SMTP', $response['body']['name']); + $this->assertEquals('email', $response['body']['type']); + $this->assertEquals('smtp', $response['body']['provider']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingProviderTwilio(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Twilio', + 'from' => '+15551234567', + 'accountSid' => 'test-account-sid', + 'authToken' => 'test-auth-token', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ + '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($providerId, $response['body']['$id']); + $this->assertEquals('Migration Twilio', $response['body']['name']); + $this->assertEquals('sms', $response['body']['type']); + $this->assertEquals('twilio', $response['body']['provider']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingTopic(): void + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Topic', + 'apiKey' => 'my-apikey', + 'from' => 'migration-topic@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $this->assertNotEmpty($topic['body']['$id']); + + $topicId = $topic['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_TOPIC, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_TOPIC]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ + '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($topicId, $response['body']['$id']); + $this->assertEquals('Migration Topic', $response['body']['name']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingSubscriber(): void + { + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sub@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertEquals(1, \count($user['body']['targets'])); + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Subscriber', + 'apiKey' => 'my-apikey', + 'from' => uniqid() . '-migration-sub@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Subscriber Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'subscriberId' => ID::unique(), + 'targetId' => $targetId, + ]); + + $this->assertEquals(201, $subscriber['headers']['status-code']); + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_SUBSCRIBER, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ + '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($topicId, $response['body']['$id']); + $this->assertGreaterThanOrEqual(1, $response['body']['emailTotal']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-msg@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertEquals(1, \count($user['body']['targets'])); + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Message', + 'apiKey' => 'my-apikey', + 'from' => 'migration-msg@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Message Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'targets' => [$targetId], + 'topics' => [$topicId], + 'subject' => 'Migration Test Email', + 'content' => 'This is a migration test email', + 'draft' => true, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $this->assertNotEmpty($message['body']['$id']); + + $messageId = $message['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + '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($messageId, $response['body']['$id']); + $this->assertEquals('draft', $response['body']['status']); + $this->assertEquals('Migration Test Email', $response['body']['data']['subject']); + $this->assertEquals('This is a migration test email', $response['body']['data']['content']); + $this->assertContains($topicId, $response['body']['topics']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingSmsMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sms@test.com', + 'phone' => '+1' . str_pad((string) rand(200000000, 999999999), 10, '0', STR_PAD_LEFT), + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $this->assertGreaterThanOrEqual(1, \count($user['body']['targets'])); + + $smsTarget = null; + foreach ($user['body']['targets'] as $target) { + if ($target['providerType'] === 'sms') { + $smsTarget = $target; + break; + } + } + $this->assertNotNull($smsTarget); + $targetId = $smsTarget['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Twilio SMS Msg', + 'from' => '+15559876543', + 'accountSid' => 'test-account-sid', + 'authToken' => 'test-auth-token', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration SMS Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/sms', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'targets' => [$targetId], + 'topics' => [$topicId], + 'content' => 'Migration SMS test content', + 'draft' => true, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $messageId = $message['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + '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($messageId, $response['body']['$id']); + $this->assertEquals('draft', $response['body']['status']); + $this->assertEquals('Migration SMS test content', $response['body']['data']['content']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + public function testAppwriteMigrationMessagingScheduledMessage(): void + { + $this->getDestinationProject(true); + + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'userId' => ID::unique(), + 'email' => uniqid() . '-migration-sched@test.com', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + $targetId = $user['body']['targets'][0]['$id']; + + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'providerId' => ID::unique(), + 'name' => 'Migration Sendgrid Scheduled', + 'apiKey' => 'my-apikey', + 'from' => 'migration-sched@test.com', + ]); + + $this->assertEquals(201, $provider['headers']['status-code']); + $providerId = $provider['body']['$id']; + + $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'topicId' => ID::unique(), + 'name' => 'Migration Scheduled Topic', + ]); + + $this->assertEquals(201, $topic['headers']['status-code']); + $topicId = $topic['body']['$id']; + + $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'subscriberId' => ID::unique(), + 'targetId' => $targetId, + ]); + + $this->assertEquals(201, $subscriber['headers']['status-code']); + + // Create a scheduled message with a future date using topics only + // Direct targets use source IDs which won't resolve in the destination via API + $futureDate = (new \DateTime('+1 year'))->format(\DateTime::ATOM); + $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'messageId' => ID::unique(), + 'topics' => [$topicId], + 'subject' => 'Migration Scheduled Email', + 'content' => 'This is a scheduled migration test email', + 'scheduledAt' => $futureDate, + ]); + + $this->assertEquals(201, $message['headers']['status-code']); + $messageId = $message['body']['$id']; + $this->assertEquals('scheduled', $message['body']['status']); + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_USER, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); + + $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + '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($messageId, $response['body']['$id']); + $this->assertEquals('scheduled', $response['body']['status']); + $this->assertEquals('Migration Scheduled Email', $response['body']['data']['subject']); + $this->assertEquals( + (new \DateTime($futureDate))->getTimestamp(), + (new \DateTime($response['body']['scheduledAt']))->getTimestamp(), + ); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + /** + * Import VectorsDB documents from CSV + */ + public function testImportVectordbCSV(): void + { + $databaseId = null; + $collectionId = null; + $bucketId = null; + + try { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Vector CSV Import DB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Vector CSV Import Collection', + 'dimension' => 3, + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Vector CSV Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['csv'], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/vectorsdb-documents.csv'), 'text/csv', 'vectorsdb-documents.csv'), + ]); + + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + $migration = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + + $this->assertEventually(function () use ($migration) { + $migrationId = $migration['body']['$id']; + $status = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $status['headers']['status-code']); + $this->assertEquals('finished', $status['body']['stage']); + $this->assertEquals('completed', $status['body']['status']); + $this->assertContains(Resource::TYPE_DOCUMENT, $status['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $status['body']['statusCounters']); + $this->assertEquals(2, $status['body']['statusCounters'][Resource::TYPE_DOCUMENT]['success']); + + return true; + }, 60_000, 500); + + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'queries' => [ + Query::limit(10)->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(2, $documents['body']['total']); + + $titles = array_map(fn ($doc) => $doc['metadata']['title'] ?? null, $documents['body']['documents']); + $this->assertContains('Vector Alpha', $titles); + $this->assertContains('Vector Beta', $titles); + } finally { + if ($bucketId) { + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + if ($databaseId) { + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + } + } + + /** + * Export VectorsDB documents to CSV + */ + public function testExportVectordbCSV(): void + { + $databaseId = null; + + try { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Vector CSV Export DB', + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collectionId = null; + $this->assertEventually(function () use ($databaseId, &$collectionId) { + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Vector CSV Export Collection', + 'dimension' => 3, + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + }); + + $documentsPayload = [ + [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.11, 0.22, 0.33], + 'metadata' => ['title' => 'Vector Sample One', 'category' => 'alpha'], + ], + ], + [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.44, 0.55, 0.66], + 'metadata' => ['title' => 'Vector Sample Two', 'category' => 'beta'], + ], + ], + ]; + + foreach ($documentsPayload as $payload) { + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $payload); + + $this->assertEquals(201, $response['headers']['status-code']); + } + + $filename = 'vectorsdb-export-' . ID::unique(); + $migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => $filename, + 'columns' => [], + 'queries' => [], + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + 'header' => true, + 'notify' => true, + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + + $migrationId = $migration['body']['$id']; + $this->assertEventually(function () use ($migrationId) { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('finished', $response['body']['stage']); + $this->assertEquals('completed', $response['body']['status']); + + return true; + }, 30_000, 500); + + $lastEmail = $this->getLastEmail(); + $this->assertNotEmpty($lastEmail); + $this->assertEquals('Your CSV export is ready', $lastEmail['subject']); + + \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches); + $this->assertNotEmpty($matches[1], 'Download URL not found in email'); + $downloadUrl = html_entity_decode($matches[1]); + + $components = \parse_url($downloadUrl); + $this->assertNotEmpty($components); + \parse_str($components['query'] ?? '', $queryParams); + $this->assertArrayHasKey('jwt', $queryParams); + $this->assertArrayHasKey('project', $queryParams); + + $path = \str_replace('/v1', '', $components['path']); + $downloadResponse = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); + $this->assertEquals(200, $downloadResponse['headers']['status-code']); + + $csvData = $downloadResponse['body']; + $this->assertStringContainsString('Vector Sample One', $csvData); + $this->assertStringContainsString('Vector Sample Two', $csvData); + $this->assertStringContainsString('[0.11,0.22,0.33]', $csvData); + } finally { + if ($databaseId) { + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + } + } + + /** + * DocumentsDB (schemaless) + */ + public function testAppwriteMigrationDocumentsDBDatabase(): array + { + $response = $this->client->call(Client::METHOD_POST, '/documentsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'DocsDB - Migration DB' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + + $databaseId = $response['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_DATABASE_DOCUMENTSDB], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($databaseId, $response['body']['$id']); + $this->assertEquals('DocsDB - Migration DB', $response['body']['name']); + + // Cleanup on destination + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + ]; + } + + /** + * VectorsDB (embeddings collections) + */ + public function testAppwriteMigrationVectorsDBDatabase(): array + { + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'VDB - Migration DB' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + + $databaseId = $response['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_DATABASE_VECTORSDB], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error'] ?? 0); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($databaseId, $response['body']['$id']); + $this->assertEquals('VDB - Migration DB', $response['body']['name']); + + // Cleanup on destination + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + ]; + } + + #[Depends('testAppwriteMigrationVectorsDBDatabase')] + public function testAppwriteMigrationVectorsDBCollection(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'VDB - Movies', + 'dimension' => 3, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $collectionId = $collection['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_ATTRIBUTE, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $result['status']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($collectionId, $response['body']['$id']); + $this->assertEquals('VDB - Movies', $response['body']['name']); + // Verify attributes are present (embeddings and metadata are default attributes) + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + ]; + } + + #[Depends('testAppwriteMigrationVectorsDBCollection')] + public function testAppwriteMigrationVectorsDBDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Migration Test Movie'], + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + // Ensure attributes are exported before documents + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_DOCUMENT, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + // Verify that TYPE_ATTRIBUTE appears in the resources array for VectorsDB + $this->assertContains(Resource::TYPE_ATTRIBUTE, $result['resources'], 'TYPE_ATTRIBUTE should be in resources array for VectorsDB'); + + // Verify attributes exist on destination before checking document + $collectionResponse = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $collectionResponse['headers']['status-code']); + $this->assertArrayHasKey('attributes', $collectionResponse['body']); + $this->assertIsArray($collectionResponse['body']['attributes']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($documentId, $response['body']['$id']); + $this->assertEquals('Migration Test Movie', $response['body']['metadata']['title']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + #[Depends('testAppwriteMigrationDocumentsDBDatabase')] + public function testAppwriteMigrationDocumentsDBCollection(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'DocsDB - Movies', + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $collectionId = $collection['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, // collections in DocumentsDB map to tables in migration + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $result['status']); + foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION] as $resource) { + $this->assertArrayHasKey($resource, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][$resource]['error']); + $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); + $this->assertEquals(1, $result['statusCounters'][$resource]['success']); + $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); + $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); + } + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($collectionId, $response['body']['$id']); + $this->assertEquals('DocsDB - Movies', $response['body']['name']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + ]; + } + + #[Depends('testAppwriteMigrationDocumentsDBCollection')] + public function testAppwriteMigrationDocumentsDBDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'title' => 'Migration Test Movie', + 'releaseYear' => 1999, + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + + foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB] as $resource) { + $this->assertArrayHasKey($resource, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][$resource]['error']); + $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); + $this->assertEquals(1, $result['statusCounters'][$resource]['success']); + } + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($documentId, $response['body']['$id']); + $this->assertEquals('Migration Test Movie', $response['body']['title']); + $this->assertEquals(1999, $response['body']['releaseYear']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + /** + * Migrate a project that contains both SQL Databases (/databases) and + * schemaless DocumentsDB (/documentsdb) in a single run and verify results. + * Uses a dedicated isolated source project to avoid interference from other tests. + */ + public function testAppwriteMigrationMixedDatabases(): void + { + // Create a fresh isolated source project for this test + $sourceProject = $this->getProject(true); + + // ====== Create SQL Database (/databases) with table, column, and row ====== + $sql = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed SQL DB', + ]); + + $this->assertEquals(201, $sql['headers']['status-code']); + $this->assertNotEmpty($sql['body']['$id']); + $sqlDatabaseId = $sql['body']['$id']; + + // Create Table in SQL Database + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'tableId' => ID::unique(), + 'name' => 'Products', + ]); + + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + // Create Column in Table + $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => 'productName', + 'size' => 255, + 'required' => true, + ]); + + $this->assertEquals(202, $column['headers']['status-code']); + + // Wait for column to be ready + $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sourceProject) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + + $sqlIndexKey = 'product_unique'; + + $sqlIndex = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $sqlIndexKey, + 'type' => Database::INDEX_UNIQUE, + 'columns' => ['productName'], + ]); + + $this->assertEquals(202, $sqlIndex['headers']['status-code']); + + $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sqlIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('available', $index['body']['status']); + }, 30000, 500); + + // Create Row in Table + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'rowId' => ID::unique(), + 'data' => [ + 'productName' => 'Laptop', + ], + ]); + + $this->assertEquals(201, $row['headers']['status-code']); + $rowId = $row['body']['$id']; + + // ====== Create DocumentsDB (/documentsdb) with collection and document ====== + $docs = $this->client->call(Client::METHOD_POST, '/documentsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed DocsDB', + ]); + + $this->assertEquals(201, $docs['headers']['status-code']); + $this->assertNotEmpty($docs['body']['$id']); + $docsDatabaseId = $docs['body']['$id']; + + // Create Collection in DocumentsDB + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Users', + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $documentsIndexKey = 'email_unique'; + + $documentsIndex = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $documentsIndexKey, + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['email'], + ]); + + $this->assertEquals(202, $documentsIndex['headers']['status-code']); + + $this->assertEventually(function () use ($docsDatabaseId, $collectionId, $documentsIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('available', $index['body']['status']); + }, 30000, 500); + + // Create Document in Collection + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + // ====== Create VectorsDB (/vectorsdb) with collection and document ====== + $vector = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed VectorsDB', + ]); + + $this->assertEquals(201, $vector['headers']['status-code']); + $this->assertNotEmpty($vector['body']['$id']); + $vectorDatabaseId = $vector['body']['$id']; + + // Create Collection in VectorsDB + $vectorCollection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Products', + 'dimension' => 3, + ]); + + $this->assertEquals(201, $vectorCollection['headers']['status-code']); + $vectorCollectionId = $vectorCollection['body']['$id']; + + // Wait for VectorsDB collection attributes to be ready + $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $sourceProject) { + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + // Check that default attributes (embeddings and metadata) are present and ready + $attributeKeys = array_column($response['body']['attributes'], 'key'); + $this->assertContains('embeddings', $attributeKeys); + $this->assertContains('metadata', $attributeKeys); + // Check that attributes are available (if status field exists) + foreach ($response['body']['attributes'] as $attribute) { + if (isset($attribute['status']) && $attribute['status'] !== 'available') { + return false; + } + } + return true; + }, 10000, 500); + + $metadataIndexKey = '_key_metadata'; + $vectorIndexes = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + $this->assertEquals(200, $vectorIndexes['headers']['status-code']); + $metadataIndex = null; + foreach ($vectorIndexes['body']['indexes'] ?? [] as $index) { + if (($index['key'] ?? '') === $metadataIndexKey) { + $metadataIndex = $index; + break; + } + } + $this->assertNotNull($metadataIndex, 'Default metadata index should exist on source collection'); + $this->assertEquals(Database::INDEX_OBJECT, $metadataIndex['type']); + + $vectorEmbeddingIndexKey = 'embedding_euclidean'; + $vectorEmbeddingIndex = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $vectorEmbeddingIndexKey, + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'], + ]); + $this->assertEquals(202, $vectorEmbeddingIndex['headers']['status-code']); + + $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $vectorEmbeddingIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes/' . $vectorEmbeddingIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $index['body']['type']); + if (isset($index['body']['status'])) { + $this->assertEquals('available', $index['body']['status']); + } + }, 30000, 500); + + // Create Document in VectorsDB Collection + $vectorDocument = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.5, 0.3, 0.2], + 'metadata' => ['name' => 'Product Vector'], + ], + ]); + + $this->assertEquals(201, $vectorDocument['headers']['status-code']); + $vectorDocumentId = $vectorDocument['body']['$id']; + + // ====== Perform migration including all three database kinds with all child resources ====== + $migrationConfig = [ + 'resources' => [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_INDEX, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $sourceProject['$id'], + 'apiKey' => $sourceProject['apiKey'], + ]; + + // Perform migration sync once and get migration ID + $result = $this->performMigrationSync($migrationConfig); + $migrationId = $result['$id']; + $this->assertEquals('completed', $result['status']); + $this->assertEquals('Appwrite', $result['source']); + $this->assertEquals('Appwrite', $result['destination']); + $this->assertEquals([ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_INDEX, + ], $result['resources']); + + // Get migration status before asserting SQL Database counters + $result = $this->getMigrationStatus($migrationId); + // Assert SQL Database counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['warning']); + + // Get migration status before asserting Table counters + $result = $this->getMigrationStatus($migrationId); + // Assert Table counters + $this->assertArrayHasKey(Resource::TYPE_TABLE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_TABLE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['warning']); + + // Get migration status before asserting Column counters + $result = $this->getMigrationStatus($migrationId); + // Assert Column counters + $this->assertArrayHasKey(Resource::TYPE_COLUMN, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_COLUMN]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['warning']); + + // Get migration status before asserting Row counters + $result = $this->getMigrationStatus($migrationId); + // Assert Row counters + $this->assertArrayHasKey(Resource::TYPE_ROW, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_ROW]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['warning']); + + // Get migration status before asserting DocumentsDB counters + $result = $this->getMigrationStatus($migrationId); + // Assert DocumentsDB counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); + + // Wait for all collections to be fully processed and status counters to be updated + // Note: Collections are being transferred but status counters may not be updated immediately + // This wait ensures the migration worker has finished processing all collections + $result = null; + $this->assertEventually(function () use ($migrationId, &$result) { + $result = $this->getMigrationStatus($migrationId); + + // Check if collections status counters exist + if (!isset($result['statusCounters'][Resource::TYPE_COLLECTION])) { + return false; + } + + $pendingCount = $result['statusCounters'][Resource::TYPE_COLLECTION]['pending'] ?? 0; + + // Return true only when pending count is 0 + return $pendingCount === 0; + }, 30000, 1000); // 30 second timeout, check every 1 second + + // Assert Collection counters (covers both DocumentsDB and VectorsDB collections) + $this->assertArrayHasKey(Resource::TYPE_COLLECTION, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_COLLECTION]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['warning']); + + // Get migration status before asserting Document counters + $result = $this->getMigrationStatus($migrationId); + // Assert Document counters (covers both DocumentsDB and VectorsDB documents) + $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_DOCUMENT]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['warning']); + + // Get migration status before asserting VectorsDB counters + $result = $this->getMigrationStatus($migrationId); + // Assert VectorsDB counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['warning']); + + // Get migration status before asserting Attribute counters + $result = $this->getMigrationStatus($migrationId); + // Assert Attribute counters (for VectorsDB) + $this->assertArrayHasKey(Resource::TYPE_ATTRIBUTE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['warning']); + + // Get migration status before asserting Index counters + $result = $this->getMigrationStatus($migrationId); + $this->assertArrayHasKey(Resource::TYPE_INDEX, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['pending']); + $this->assertGreaterThanOrEqual(4, $result['statusCounters'][Resource::TYPE_INDEX]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['warning']); + + // Get migration status before asserting counter count + $result = $this->getMigrationStatus($migrationId); + // Ensure only expected counters exist (10 total) + $this->assertCount(10, $result['statusCounters']); + + // ====== Validate on destination: SQL Database resources ====== + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $sqlDatabaseId, [ + '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($sqlDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed SQL DB', $response['body']['name']); + + // Validate Table + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId, [ + '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($tableId, $response['body']['$id']); + $this->assertEquals('Products', $response['body']['name']); + + // Validate Column + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ + '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('productName', $response['body']['key']); + $this->assertEquals(255, $response['body']['size']); + $this->assertEquals(true, $response['body']['required']); + + // Validate Row + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + '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($rowId, $response['body']['$id']); + $this->assertEquals('Laptop', $response['body']['productName']); + + $sqlIndexDestination = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $sqlIndexDestination['headers']['status-code']); + $this->assertEquals($sqlIndexKey, $sqlIndexDestination['body']['key']); + $this->assertEquals(Database::INDEX_UNIQUE, $sqlIndexDestination['body']['type']); + if (isset($sqlIndexDestination['body']['columns'])) { + $this->assertEquals(['productName'], $sqlIndexDestination['body']['columns']); + } + + // ====== Validate on destination: DocumentsDB resources ====== + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId, [ + '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($docsDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed DocsDB', $response['body']['name']); + + // Validate Collection + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId, [ + '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($collectionId, $response['body']['$id']); + $this->assertEquals('Users', $response['body']['name']); + + // Validate Document + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + '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($documentId, $response['body']['$id']); + $this->assertEquals('John Doe', $response['body']['name']); + $this->assertEquals('john@example.com', $response['body']['email']); + + $documentsIndexDestination = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $documentsIndexDestination['headers']['status-code']); + $this->assertEquals($documentsIndexKey, $documentsIndexDestination['body']['key']); + $this->assertEquals(Database::INDEX_UNIQUE, $documentsIndexDestination['body']['type']); + if (isset($documentsIndexDestination['body']['attributes'])) { + $this->assertEquals(['email'], $documentsIndexDestination['body']['attributes']); + } + + // ====== Validate on destination: VectorsDB resources ====== + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId, [ + '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($vectorDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed VectorsDB', $response['body']['name']); + + // Validate VectorsDB Collection + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ + '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($vectorCollectionId, $response['body']['$id']); + $this->assertEquals('Products', $response['body']['name']); + // Verify attributes are present (embeddings and metadata are default attributes) + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + + $vectorIndexesDestination = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $vectorIndexesDestination['headers']['status-code']); + $indexByKey = []; + foreach ($vectorIndexesDestination['body']['indexes'] ?? [] as $index) { + if (isset($index['key'])) { + $indexByKey[$index['key']] = $index; + } + } + $this->assertArrayHasKey($metadataIndexKey, $indexByKey, 'Metadata index should exist on destination'); + $this->assertEquals(Database::INDEX_OBJECT, $indexByKey[$metadataIndexKey]['type']); + $this->assertArrayHasKey($vectorEmbeddingIndexKey, $indexByKey, 'Embeddings HNSW index should exist on destination'); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $indexByKey[$vectorEmbeddingIndexKey]['type']); + + // Validate VectorsDB Document + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents/' . $vectorDocumentId, [ + '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($vectorDocumentId, $response['body']['$id']); + $this->assertEquals('Product Vector', $response['body']['metadata']['name']); + + // ====== Cleanup all destinations ====== + $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + // ====== Cleanup sources ====== + $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index e8dac843b4..dc31b7aa85 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -26,26 +26,39 @@ trait ProjectsBase return self::$cachedProjectData; } - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); + $teamId = ID::unique(); + $team = null; + for ($i = 0; $i < 3; $i++) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Project Test', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + $this->assertContains($team['headers']['status-code'], [201, 409]); + $project = null; + for ($i = 0; $i < 3; $i++) { + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'] ?? $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + if ($project['headers']['status-code'] === 201) { + break; + } + \usleep(500000); + } $this->assertEquals(201, $project['headers']['status-code']); self::$cachedProjectData = [ @@ -396,27 +409,42 @@ trait ProjectsBase protected function setupProject(mixed $params, ?string $teamId = null, bool $newTeam = true): string { if ($newTeam) { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + $generatedTeamId = $teamId ?? ID::unique(); + $team = null; + for ($i = 0; $i < 3; $i++) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $generatedTeamId, + 'name' => 'Project Test', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + + $this->assertContains($team['headers']['status-code'], [201, 409], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); + + $teamId = $team['body']['$id'] ?? $generatedTeamId; + } + + $project = null; + for ($i = 0; $i < 3; $i++) { + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'teamId' => $teamId ?? ID::unique(), - 'name' => 'Project Test', + ...$params, + 'teamId' => $teamId, ]); - - $this->assertEquals(201, $team['headers']['status-code'], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); - - $teamId = $team['body']['$id']; + if ($project['headers']['status-code'] === 201) { + break; + } + \usleep(500000); } - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - ...$params, - 'teamId' => $teamId, - ]); - $this->assertEquals(201, $project['headers']['status-code'], 'Setup project failed with status code: ' . $project['headers']['status-code'] . ' and response: ' . json_encode($project['body'], JSON_PRETTY_PRINT)); return $project['body']['$id']; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f5937bccfa..28cb146508 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -13,6 +13,8 @@ use Tests\E2E\Scopes\SideClient; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\System\System; @@ -106,6 +108,111 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(401, $response['headers']['status-code']); } + public function testDeleteProjectWithMultiDB(): void + { + // Create a team and project + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'MultiDB Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'MultiDB Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + $projectAdminHeaders = array_merge($this->getHeaders(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + ]); + + // Create legacy database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Legacy DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Legacy Collection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + + // Create documentsdb database and collection + $documentsDb = $this->client->call(Client::METHOD_POST, '/documentsdb', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Documents DB', + ]); + $this->assertEquals(201, $documentsDb['headers']['status-code']); + $documentsDbId = $documentsDb['body']['$id']; + + $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDbId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Documents Collection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $documentsCollection['headers']['status-code']); + + // Create vectorsdb database and collection + $vectorDb = $this->client->call(Client::METHOD_POST, '/vectorsdb', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Vector DB', + ]); + $this->assertEquals(201, $vectorDb['headers']['status-code']); + $vectorDbId = $vectorDb['body']['$id']; + + $vectorCollection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDbId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Vector Collection', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $vectorCollection['headers']['status-code']); + + // Delete project + $delete = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $delete['headers']['status-code']); + + // Ensure project is gone + $getProject = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $getProject['headers']['status-code']); + } + public function testCreateDuplicateProject(): void { // Create a team @@ -951,26 +1058,22 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); - $emails = $this->getLastEmail(2); - $this->assertCount(2, $emails); - $this->assertEquals('custommailer@appwrite.io', $emails[0]['from'][0]['address']); - $this->assertEquals('Custom Mailer', $emails[0]['from'][0]['name']); - $this->assertEquals('reply@appwrite.io', $emails[0]['replyTo'][0]['address']); - $this->assertEquals('Custom Mailer', $emails[0]['replyTo'][0]['name']); - $this->assertEquals('Custom SMTP email sample', $emails[0]['subject']); - $this->assertStringContainsStringIgnoringCase('working correctly', $emails[0]['text']); - $this->assertStringContainsStringIgnoringCase('working correctly', $emails[0]['html']); - $this->assertStringContainsStringIgnoringCase('251 Little Falls Drive', $emails[0]['text']); - $this->assertStringContainsStringIgnoringCase('251 Little Falls Drive', $emails[0]['html']); + $smtpProbe = function ($email) { + $this->assertEquals('Custom SMTP email sample', $email['subject']); + }; + $email1 = $this->getLastEmailByAddress('testuser@appwrite.io', $smtpProbe); + $email2 = $this->getLastEmailByAddress('testusertwo@appwrite.io', $smtpProbe); - $to = [ - $emails[0]['to'][0]['address'], - $emails[1]['to'][0]['address'] - ]; - \sort($to); - - $this->assertEquals('testuser@appwrite.io', $to[0]); - $this->assertEquals('testusertwo@appwrite.io', $to[1]); + $this->assertEquals('custommailer@appwrite.io', $email1['from'][0]['address']); + $this->assertEquals('Custom Mailer', $email1['from'][0]['name']); + $this->assertEquals('reply@appwrite.io', $email1['replyTo'][0]['address']); + $this->assertEquals('Custom Mailer', $email1['replyTo'][0]['name']); + $this->assertEquals('Custom SMTP email sample', $email1['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email1['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email1['html']); + $this->assertStringContainsStringIgnoringCase('251 Little Falls Drive', $email1['text']); + $this->assertStringContainsStringIgnoringCase('251 Little Falls Drive', $email1['html']); + $this->assertEquals('custommailer@appwrite.io', $email2['from'][0]['address']); $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/smtp/tests', array_merge([ 'content-type' => 'application/json', @@ -6427,11 +6530,12 @@ class ProjectsConsoleClientTest extends Scope $userId = $response['body']['userId']; - $lastEmail = $this->getLastEmail(1, function ($email) use ($url) { + $userEmail = $this->getUser()['email']; + + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) use ($url) { $this->assertStringContainsString($url, $email['html'] ?? ''); }); - $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $expectedUrl = $url . "&userId=" . $userId . "&secret="; @@ -6450,7 +6554,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'userId' => ID::unique(), - 'email' => $this->getUser()['email'], + 'email' => $userEmail, 'url' => $url, ] ); @@ -6460,11 +6564,10 @@ class ProjectsConsoleClientTest extends Scope $userId = $response['body']['userId']; - $lastEmail = $this->getLastEmail(1, function ($email) use ($url) { + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) use ($url) { $this->assertStringContainsString($url, $email['html'] ?? ''); }); - $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $expectedUrl = $url . "&userId=" . $userId . "&secret="; @@ -6483,7 +6586,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'userId' => ID::unique(), - 'email' => $this->getUser()['email'], + 'email' => $userEmail, 'url' => $url, ] ); @@ -6493,11 +6596,10 @@ class ProjectsConsoleClientTest extends Scope $userId = $response['body']['userId']; - $lastEmail = $this->getLastEmail(1, function ($email) use ($url, $userId) { + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) use ($url, $userId) { $this->assertStringContainsString($url . '?userId=' . $userId, $email['html'] ?? ''); }); - $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $expectedUrl = $url . "?userId=" . $userId . "&secret="; @@ -6516,7 +6618,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'userId' => ID::unique(), - 'email' => $this->getUser()['email'], + 'email' => $userEmail, 'url' => $url, ] ); @@ -6526,11 +6628,10 @@ class ProjectsConsoleClientTest extends Scope $userId = $response['body']['userId']; - $lastEmail = $this->getLastEmail(1, function ($email) use ($url, $userId) { + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) use ($url, $userId) { $this->assertStringContainsString($url . '?userId=' . $userId, $email['html'] ?? ''); }); - $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $expectedUrl = $url . "?userId=" . $userId . "&secret="; @@ -6549,7 +6650,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'userId' => ID::unique(), - 'email' => $this->getUser()['email'], + 'email' => $userEmail, 'url' => $url, ] ); @@ -6559,11 +6660,10 @@ class ProjectsConsoleClientTest extends Scope $userId = $response['body']['userId']; - $lastEmail = $this->getLastEmail(1, function ($email) { + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { $this->assertStringContainsString('INJECTED', $email['html'] ?? ''); }); - $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $this->assertStringContainsString('INJECTED', $lastEmail['html']); diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php index cd3be80149..681e39b662 100644 --- a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php @@ -16,26 +16,39 @@ trait SchedulesBase return self::$cachedScheduleProjectData; } - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Schedule Test Team', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'projectId' => ID::unique(), - 'name' => 'Schedule Test Project', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); + $teamId = ID::unique(); + $team = null; + for ($i = 0; $i < 3; $i++) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Schedule Test Team', + ]); + if (\in_array($team['headers']['status-code'], [201, 409])) { + break; + } + \usleep(500000); + } + $this->assertContains($team['headers']['status-code'], [201, 409]); + $project = null; + for ($i = 0; $i < 3; $i++) { + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Schedule Test Project', + 'teamId' => $team['body']['$id'] ?? $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + if ($project['headers']['status-code'] === 201) { + break; + } + \usleep(500000); + } $this->assertEquals(201, $project['headers']['status-code']); $projectId = $project['body']['$id']; diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index ee7946d9f7..95f3665e4c 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -11,7 +11,8 @@ trait RealtimeBase array $channels = [], array $headers = [], ?string $projectId = null, - ?array $queries = null + ?array $queries = null, + int $timeout = 2 ): WebSocketClient { if (is_null($projectId)) { $projectId = $this->getProject()['$id']; @@ -63,7 +64,7 @@ trait RealtimeBase "ws://appwrite.test/v1/realtime?" . $queryString, [ "headers" => $headers, - "timeout" => 45, + "timeout" => $timeout, ] ); } @@ -74,9 +75,10 @@ trait RealtimeBase * * @param array $queryParams Custom query parameters (e.g., ['channels' => ['project'], 'project' => [...]]) * @param array $headers HTTP headers + * @param int $timeout Timeout in seconds (default: 2) * @return WebSocketClient */ - private function getWebsocketWithCustomQuery(array $queryParams, array $headers = []): WebSocketClient + private function getWebsocketWithCustomQuery(array $queryParams, array $headers = [], int $timeout = 2): WebSocketClient { $queryString = http_build_query($queryParams); @@ -84,7 +86,7 @@ trait RealtimeBase "ws://appwrite.test/v1/realtime?" . $queryString, [ "headers" => $headers, - "timeout" => 45, + "timeout" => $timeout, ] ); } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 8104fa7bd0..30cc70e981 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2535,29 +2535,35 @@ class RealtimeCustomClientQueryTest extends Scope $projectId = 'console'; // Subscribe without queries - should receive all events - $clientNoQuery = $this->getWebsocket(['tests'], [ - 'origin' => 'http://localhost', - ], $projectId); + $clientNoQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + timeout: 5 + ); $response = json_decode($clientNoQuery->receive(), true); $this->assertEquals('connected', $response['type']); // Subscribe with matching query - should receive events - $clientWithMatchingQuery = $this->getWebsocket(['tests'], [ - 'origin' => 'http://localhost', - ], $projectId, [ - Query::equal('response', ['WS:/v1/realtime:passed'])->toString(), - ]); + $clientWithMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()], + timeout: 5 + ); $response = json_decode($clientWithMatchingQuery->receive(), true); $this->assertEquals('connected', $response['type']); // Subscribe with non-matching query - should NOT receive events - $clientWithNonMatchingQuery = $this->getWebsocket(['tests'], [ - 'origin' => 'http://localhost', - ], $projectId, [ - Query::equal('response', ['failed'])->toString(), - ]); + $clientWithNonMatchingQuery = $this->getWebsocket( + channels: ['tests'], + headers: ['origin' => 'http://localhost'], + projectId: $projectId, + queries: [Query::equal('response', ['failed'])->toString()] + ); $response = json_decode($clientWithNonMatchingQuery->receive(), true); $this->assertEquals('connected', $response['type']); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index b94e382fde..774d0016f9 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -2225,10 +2225,14 @@ class RealtimeCustomClientTest extends Scope $session = $user['session'] ?? ''; $projectId = $this->getProject()['$id']; - $client = $this->getWebsocket(['executions'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session - ]); + $client = $this->getWebsocket( + channels: ['executions'], + headers: [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ], + timeout: 10 + ); $response = json_decode($client->receive(), true); @@ -3880,4 +3884,1368 @@ class RealtimeCustomClientTest extends Scope } }); } + public function testChannelTablesDB() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + /** + * Test Database Create + */ + $database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test Collection Create + */ + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $name = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEquals(202, $name['headers']['status-code']); + $this->assertEquals('name', $name['body']['key']); + $this->assertEquals('string', $name['body']['type']); + $this->assertEquals(256, $name['body']['size']); + $this->assertTrue($name['body']['required']); + + sleep(2); + + /** + * Test Document Create + */ + $document = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $rowId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.tables.' . $actorsId . '.rows.' . $rowId, $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.tables.' . $actorsId . '.rows', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Chris Evans', $response['data']['payload']['name']); + + /** + * Test Document Update + */ + $document = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans 2' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + + $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); + + /** + * Test Document Delete + */ + $document = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Bradley Cooper' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $client->receive(); + + $rowId = $document['body']['$id']; + + $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('rows', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['channels']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Bradley Cooper', $response['data']['payload']['name']); + + // test bulk create + $documents = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rows' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + '$id' => ID::unique(), + 'name' => 'Scarlett Johansson', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first document event + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // Receive second document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // test bulk update + $response = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'name' => 'Marvel Hero', + '$permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive second document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive third document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Test bulk delete + $response = $this->client->call(Client::METHOD_DELETE, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive second document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive third document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // bulk upsert + $this->client->call(Client::METHOD_PUT, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rows' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + $client->close(); + } + public function testChannelDocumentsdb() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + /** + * Test Database Create + */ + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test Collection Create + */ + $actors = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + /** + * Test Document Create + */ + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $documentId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Chris Evans', $response['data']['payload']['name']); + + /** + * Test Document Update + */ + $document = $this->client->call(Client::METHOD_PATCH, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans 2' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + + $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); + + /** + * Test Document Delete + */ + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Bradley Cooper' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $client->receive(); + + $documentId = $document['body']['$id']; + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Bradley Cooper', $response['data']['payload']['name']); + + // test bulk create + $documents = $this->client->call(Client::METHOD_POST, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + '$id' => ID::unique(), + 'name' => 'Scarlett Johansson', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // Receive second document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // test bulk update + $response = $this->client->call(Client::METHOD_PATCH, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'name' => 'Marvel Hero', + '$permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive second document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive third document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Test bulk delete + $response = $this->client->call(Client::METHOD_DELETE, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive second document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive third document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // bulk upsert + $this->client->call(Client::METHOD_PUT, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + $client->close(); + } + + public function testChannelVectorsDB() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + // Create VectorsDB database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors VDB', + ]); + + $databaseId = $database['body']['$id']; + + // Create collection in VectorsDB + $actors = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + + $actorsId = $actors['body']['$id']; + + // Create document in VectorsDB + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'Chris Evans'] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $documentId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + // vectorsdb channels should include 3 items like documentsdb + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']['embeddings']); + $this->assertCount(3, $response['data']['payload']['embeddings']); + $this->assertEquals('Chris Evans', $response['data']['payload']['metadata']['name']); + + // Update document + $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'Chris Evans 2'] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']['embeddings']); + $this->assertEquals('Chris Evans 2', $response['data']['payload']['metadata']['name']); + + // Delete document + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + + // Bulk create two documents + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'Robert Downey Jr.'], + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'Scarlett Johansson'], + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first bulk document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); + $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); + $this->assertContains('vectorsdb.*.collections.' . $actorsId . '.documents.*.create', $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + + // Receive second bulk document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); + + $client->close(); + } } diff --git a/tests/e2e/Services/Sites/SitesConsoleClientTest.php b/tests/e2e/Services/Sites/SitesConsoleClientTest.php index d8a352843e..2a94dded5f 100644 --- a/tests/e2e/Services/Sites/SitesConsoleClientTest.php +++ b/tests/e2e/Services/Sites/SitesConsoleClientTest.php @@ -7,6 +7,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\Console; use Utopia\Database\Helpers\ID; class SitesConsoleClientTest extends Scope @@ -147,4 +148,52 @@ class SitesConsoleClientTest extends Scope $this->cleanupSite($siteId); } + + public function testSiteDeploymentRetentionWithMaintenance(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 180, + 'buildRuntime' => 'node-22', + ]); + $this->assertNotEmpty($siteId); + + $deploymentIdInactive = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactive); + + $deploymentIdInactiveOld = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactiveOld); + + $deploymentIdActive = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdActive); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite-task-maintenance time-travel --projectId={$this->getProject()['$id']} --resourceType=deployment --resourceId={$deploymentIdInactiveOld} --createdAt=2020-01-01T00:00:00Z", '', $stdout, $stderr); + $this->assertSame(0, $code, "Time-travel command failed with code $code: $stderr ($stdout)"); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite-task-maintenance maintenance --type=trigger", '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + $this->assertEventually(function () use ($siteId) { + $response = $this->listDeployments($siteId); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + }); + + $this->cleanupSite($siteId); + } } diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index dbed1e884b..9bb4a34ff5 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -26,26 +26,34 @@ class SitesCustomServerTest extends Scope { $specifications = $this->listSpecifications(); $this->assertEquals(200, $specifications['headers']['status-code']); - $this->assertGreaterThan(0, $specifications['body']['total']); + $this->assertGreaterThanOrEqual(2, $specifications['body']['total']); $this->assertArrayHasKey(0, $specifications['body']['specifications']); + $this->assertArrayHasKey(1, $specifications['body']['specifications']); $this->assertArrayHasKey('memory', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('cpus', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('enabled', $specifications['body']['specifications'][0]); $this->assertArrayHasKey('slug', $specifications['body']['specifications'][0]); + $this->assertArrayHasKey('memory', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('cpus', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('enabled', $specifications['body']['specifications'][1]); + $this->assertArrayHasKey('slug', $specifications['body']['specifications'][1]); $site = $this->createSite([ 'buildRuntime' => 'node-22', 'framework' => 'other', 'name' => 'Specs site', 'siteId' => ID::unique(), - 'specification' => $specifications['body']['specifications'][0]['slug'] + 'buildSpecification' => $specifications['body']['specifications'][0]['slug'], + 'runtimeSpecification' => $specifications['body']['specifications'][1]['slug'], ]); $this->assertEquals(201, $site['headers']['status-code']); - $this->assertEquals($specifications['body']['specifications'][0]['slug'], $site['body']['specification']); + $this->assertEquals($specifications['body']['specifications'][0]['slug'], $site['body']['buildSpecification']); + $this->assertEquals($specifications['body']['specifications'][1]['slug'], $site['body']['runtimeSpecification']); $site = $this->getSite($site['body']['$id']); $this->assertEquals(200, $site['headers']['status-code']); - $this->assertEquals($specifications['body']['specifications'][0]['slug'], $site['body']['specification']); + $this->assertEquals($specifications['body']['specifications'][0]['slug'], $site['body']['buildSpecification']); + $this->assertEquals($specifications['body']['specifications'][1]['slug'], $site['body']['runtimeSpecification']); $this->cleanupSite($site['body']['$id']); @@ -54,7 +62,16 @@ class SitesCustomServerTest extends Scope 'framework' => 'other', 'name' => 'Specs site', 'siteId' => ID::unique(), - 'specification' => 'cheap-please' + 'buildSpecification' => 'cheap-please' + ]); + $this->assertEquals(400, $site['headers']['status-code']); + + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'framework' => 'other', + 'name' => 'Specs site', + 'siteId' => ID::unique(), + 'runtimeSpecification' => 'cheap-please' ]); $this->assertEquals(400, $site['headers']['status-code']); } @@ -1292,12 +1309,12 @@ class SitesCustomServerTest extends Scope 'providerBranch' => 'main', 'providerRootDirectory' => './', '$id' => $siteId, - 'specification' => Specification::S_1VCPU_1GB, + 'runtimeSpecification' => Specification::S_1VCPU_1GB, ]); $this->assertEquals(200, $site['headers']['status-code']); $this->assertNotEmpty($site['body']['$id']); - $this->assertEquals(Specification::S_1VCPU_1GB, $site['body']['specification']); + $this->assertEquals(Specification::S_1VCPU_1GB, $site['body']['runtimeSpecification']); // Change the specs to 1vcpu 512mb $site = $this->updateSite([ @@ -1309,12 +1326,12 @@ class SitesCustomServerTest extends Scope 'providerBranch' => 'main', 'providerRootDirectory' => './', '$id' => $siteId, - 'specification' => Specification::S_1VCPU_512MB, + 'runtimeSpecification' => Specification::S_1VCPU_512MB, ]); $this->assertEquals(200, $site['headers']['status-code']); $this->assertNotEmpty($site['body']['$id']); - $this->assertEquals(Specification::S_1VCPU_512MB, $site['body']['specification']); + $this->assertEquals(Specification::S_1VCPU_512MB, $site['body']['runtimeSpecification']); /** * Test for FAILURE @@ -1329,11 +1346,26 @@ class SitesCustomServerTest extends Scope 'providerBranch' => 'main', 'providerRootDirectory' => './', '$id' => $siteId, - 'specification' => 's-2vcpu-512mb', // Invalid specification + 'buildSpecification' => 's-2vcpu-512mb', // Invalid specification ]); $this->assertEquals(400, $site['headers']['status-code']); - $this->assertStringStartsWith('Invalid `specification` param: Specification must be one of:', $site['body']['message']); + $this->assertStringStartsWith('Invalid `buildSpecification` param: Specification must be one of:', $site['body']['message']); + + $site = $this->updateSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + '$id' => $siteId, + 'runtimeSpecification' => 's-2vcpu-512mb', // Invalid specification + ]); + + $this->assertEquals(400, $site['headers']['status-code']); + $this->assertStringStartsWith('Invalid `runtimeSpecification` param: Specification must be one of:', $site['body']['message']); $this->cleanupSite($siteId); } @@ -3006,4 +3038,244 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + + public function testSiteCustomStartCommand(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Astro site', + 'framework' => 'astro', + 'adapter' => 'ssr', + 'startCommand' => 'node custom-server.js', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './dist', + 'buildCommand' => 'npm run build', + 'installCommand' => 'npm install', + 'fallbackFile' => '', + ]); + + $this->assertNotEmpty($siteId); + + $domain = $this->setupSiteDomain($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('astro-custom-start-command'), + 'activate' => 'true' + ]); + + $this->assertNotEmpty($deploymentId); + + $domain = $this->getSiteDomain($siteId); + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Homepage OK", $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/ssr'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("SSR OK", $response['body']); + $originalBody = $response['body']; + $response = $proxyClient->call(Client::METHOD_GET, '/ssr'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("SSR OK", $response['body']); + $this->assertNotEquals($originalBody, $response['body']); // Includes Date.now() + + $response = $proxyClient->call(Client::METHOD_GET, '/ssr-custom'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Custom SSR OK", $response['body']); + $originalBody = $response['body']; + $response = $proxyClient->call(Client::METHOD_GET, '/ssr-custom'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Custom SSR OK", $response['body']); + $this->assertNotEquals($originalBody, $response['body']); // Includes Date.now() + + $response = $proxyClient->call(Client::METHOD_GET, '/non-existing'); + $this->assertEquals(500, $response['headers']['status-code']); + $this->assertStringContainsString("Custom error", $response['body']); + + $this->cleanupSite($siteId); + } + + public function testSiteSpecifications() + { + // Check if the site specifications are correctly set in builds + $site = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Astro site', + 'framework' => 'astro', + 'adapter' => 'ssr', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './dist', + 'buildCommand' => 'npm run build && echo $APPWRITE_SITE_MEMORY:$APPWRITE_SITE_CPUS', + 'installCommand' => 'npm ci', + 'fallbackFile' => '', + 'buildSpecification' => Specification::S_1VCPU_1GB, + 'runtimeSpecification' => Specification::S_05VCPU_512MB, + ]); + + $this->assertEquals(201, $site['headers']['status-code']); + $this->assertEquals(Specification::S_1VCPU_1GB, $site['body']['buildSpecification']); + $this->assertEquals(Specification::S_05VCPU_512MB, $site['body']['runtimeSpecification']); + $this->assertNotEmpty($site['body']['$id']); + + $siteId = $site['body']['$id'] ?? ''; + + $domain = $this->setupSiteDomain($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('astro'), + 'activate' => true + ]); + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + // TODO: This assertion is not testing what we set in create function, because build worker currently overrides to minimal build specs + $this->assertStringContainsString('2048:1', $deployment['body']['buildLogs']); + }, 10000, 500); + + // Check if the sites specifications are correctly set in executions + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/specs'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals('512', $response['body']['APPWRITE_SITE_MEMORY']); + $this->assertEquals('0.5', $response['body']['APPWRITE_SITE_CPUS']); + + $this->cleanupSite($siteId); + } + + public function testSiteDeploymentRetention(): void + { + $siteIds = []; + + // Default + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Success values + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 0 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + + // Failure values + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + // Update flow + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + $siteIdToUpdate = $response['body']['$id']; + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 90 + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->getSite($siteIdToUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->getSite($siteIdToUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Failed update flow + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + foreach ($siteIds as $siteId) { + $this->cleanupSite($siteId); + } + } } diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 5c7b289722..866ee591a2 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1596,7 +1596,7 @@ trait UsersBase ]); $this->assertEquals($user['headers']['status-code'], 200); - $this->assertEquals($user['body']['phone'], $updatedNumber); + $this->assertEmpty($user['body']['phone'] ?? ''); $user = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'], array_merge([ 'content-type' => 'application/json', @@ -1604,7 +1604,7 @@ trait UsersBase ], $this->getHeaders())); $this->assertEquals($user['headers']['status-code'], 200); - $this->assertEquals($user['body']['phone'], $updatedNumber); + $this->assertEmpty($user['body']['phone'] ?? ''); $updatedNumber = "+910000000000"; //dummy number $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/phone', array_merge([ @@ -1648,6 +1648,58 @@ trait UsersBase static::$userNumberUpdated = true; } + public function testUpdateTwoUsersPhoneToEmpty(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + // Create two users with distinct valid phone numbers + $user1 = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'user1-phone-empty-test@appwrite.io', + 'password' => 'password', + 'name' => 'User One', + 'phone' => '+16175551201', + ]); + $this->assertEquals(201, $user1['headers']['status-code']); + $this->assertEquals('+16175551201', $user1['body']['phone']); + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'user2-phone-empty-test@appwrite.io', + 'password' => 'password', + 'name' => 'User Two', + 'phone' => '+16175551202', + ]); + $this->assertEquals(201, $user2['headers']['status-code']); + $this->assertEquals('+16175551202', $user2['body']['phone']); + + // Update first user's phone to empty - must succeed + $response1 = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1['body']['$id'] . '/phone', $headers, [ + 'number' => '', + ]); + $this->assertEquals(200, $response1['headers']['status-code'], 'First user phone should update to empty'); + $this->assertEmpty($response1['body']['phone'] ?? ''); + + // Update second user's phone to empty - must succeed (would fail with duplicate if empty was stored as '') + $response2 = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2['body']['$id'] . '/phone', $headers, [ + 'number' => '', + ]); + $this->assertEquals(200, $response2['headers']['status-code'], 'Second user phone should update to empty without duplicate error'); + $this->assertEmpty($response2['body']['phone'] ?? ''); + + // Verify both users have empty phone via GET + $get1 = $this->client->call(Client::METHOD_GET, '/users/' . $user1['body']['$id'], $headers); + $get2 = $this->client->call(Client::METHOD_GET, '/users/' . $user2['body']['$id'], $headers); + $this->assertEquals(200, $get1['headers']['status-code']); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertEmpty($get1['body']['phone'] ?? ''); + $this->assertEmpty($get2['body']['phone'] ?? ''); + } + public function testUpdateUserNumberSearch(): void { $data = $this->ensureUserNumberUpdated(); diff --git a/tests/resources/csv/vectorsdb-documents.csv b/tests/resources/csv/vectorsdb-documents.csv new file mode 100644 index 0000000000..b0b970703e --- /dev/null +++ b/tests/resources/csv/vectorsdb-documents.csv @@ -0,0 +1,3 @@ +$id,embeddings,metadata +vector-doc-1,"[0.15,0.25,0.35]","{""title"":""Vector Alpha"",""category"":""science""}" +vector-doc-2,"[0.55,0.65,0.75]","{""title"":""Vector Beta"",""category"":""history""}" \ No newline at end of file diff --git a/tests/resources/docker/docker-compose.yml b/tests/resources/docker/docker-compose.yml index 02593f8123..47a69f077b 100644 --- a/tests/resources/docker/docker-compose.yml +++ b/tests/resources/docker/docker-compose.yml @@ -76,6 +76,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_STORAGE_ANTIVIRUS=disabled - _APP_STORAGE_LIMIT @@ -141,6 +142,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER appwrite-worker-tasks: entrypoint: worker-tasks @@ -162,6 +164,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER appwrite-worker-deletes: entrypoint: worker-deletes @@ -182,6 +185,7 @@ services: - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_DB_HOST + - _APP_DB_ADAPTER - _APP_DB_PORT - _APP_DB_SCHEMA - _APP_DB_USER diff --git a/tests/resources/postgresql/Dockerfile b/tests/resources/postgresql/Dockerfile deleted file mode 100644 index a731833b48..0000000000 --- a/tests/resources/postgresql/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -ARG POSTGRES_VERSION=17 -FROM postgres:${POSTGRES_VERSION} - -ARG POSTGRES_VERSION=17 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - postgresql-${POSTGRES_VERSION}-postgis-3 \ - postgresql-${POSTGRES_VERSION}-postgis-3-scripts \ - postgresql-${POSTGRES_VERSION}-pgvector \ - && rm -rf /var/lib/apt/lists/* diff --git a/tests/resources/sites/astro-custom-start-command/.gitignore b/tests/resources/sites/astro-custom-start-command/.gitignore new file mode 100644 index 0000000000..16d54bb13c --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/tests/resources/sites/astro-custom-start-command/astro.config.mjs b/tests/resources/sites/astro-custom-start-command/astro.config.mjs new file mode 100644 index 0000000000..b522ffdf90 --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/astro.config.mjs @@ -0,0 +1,10 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; + +// https://astro.build/config +export default defineConfig({ + adapter: node({ + mode: 'middleware', + }), +}); diff --git a/tests/resources/sites/astro-custom-start-command/custom-server.js b/tests/resources/sites/astro-custom-start-command/custom-server.js new file mode 100644 index 0000000000..1abb0c0f1c --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/custom-server.js @@ -0,0 +1,16 @@ +import express from 'express'; +import { handler as ssrHandler } from './server/entry.mjs'; + +const app = express(); +const base = '/'; +app.use(base, express.static('client/')); +app.get('/ssr-custom', (_req, res) => { + res.send('Custom SSR OK (' + Date.now() + ')'); +}); +app.use(ssrHandler); + +app.use((_req, res) => { + res.status(500).send('Custom error'); +}); + +app.listen(3000); \ No newline at end of file diff --git a/tests/resources/sites/astro-custom-start-command/package-lock.json b/tests/resources/sites/astro-custom-start-command/package-lock.json new file mode 100644 index 0000000000..f2cf215af1 --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/package-lock.json @@ -0,0 +1,5666 @@ +{ + "name": "astro-custom-start-command", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "astro-custom-start-command", + "version": "0.0.1", + "dependencies": { + "@astrojs/node": "^9.5.4", + "astro": "^5.2.5" + }, + "devDependencies": {} + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", + "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", + "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/node": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.5.4.tgz", + "integrity": "sha512-AbPSZsMGu8hXPR2XxV79RaKy8h6wijhtoqZGeUf4OXg2w1mxXlx4VnIc1D+QvtsgauSz7P5PLhmvf6w/J41GJg==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^5.17.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.0.tgz", + "integrity": "sha512-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/markdown-remark": "6.3.10", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", + "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tests/resources/sites/astro-custom-start-command/package.json b/tests/resources/sites/astro-custom-start-command/package.json new file mode 100644 index 0000000000..bf2475ef57 --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/package.json @@ -0,0 +1,21 @@ +{ + "name": "astro-custom-start-command", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build && cp custom-server.js dist/custom-server.js && rm -rf node_modules && npm install --production", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^9.5.4", + "astro": "^5.2.5" + }, + "devDependencies": { + }, + "overrides": { + "rollup": ">=4.58.1", + "svgo": ">=4.0.1" + } +} diff --git a/tests/resources/sites/astro-custom-start-command/src/pages/index.astro b/tests/resources/sites/astro-custom-start-command/src/pages/index.astro new file mode 100644 index 0000000000..97fc712e50 --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/src/pages/index.astro @@ -0,0 +1,16 @@ +--- + +--- + + + + + + + + Astro + + +

Homepage OK

+ + diff --git a/tests/resources/sites/astro-custom-start-command/src/pages/ssr.js b/tests/resources/sites/astro-custom-start-command/src/pages/ssr.js new file mode 100644 index 0000000000..98cab6d896 --- /dev/null +++ b/tests/resources/sites/astro-custom-start-command/src/pages/ssr.js @@ -0,0 +1,5 @@ +export const prerender = false; + +export const GET = async () => { + return new Response("SSR OK (" + Date.now() + ")"); +}; diff --git a/tests/resources/sites/astro/package-lock.json b/tests/resources/sites/astro/package-lock.json new file mode 100644 index 0000000000..e9d14e26db --- /dev/null +++ b/tests/resources/sites/astro/package-lock.json @@ -0,0 +1,5646 @@ +{ + "name": "my-astro-app", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-astro-app", + "version": "0.0.1", + "dependencies": { + "@astrojs/node": "^9.0.2", + "astro": "^5.2.5" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", + "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/node": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.5.4.tgz", + "integrity": "sha512-AbPSZsMGu8hXPR2XxV79RaKy8h6wijhtoqZGeUf4OXg2w1mxXlx4VnIc1D+QvtsgauSz7P5PLhmvf6w/J41GJg==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^5.17.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.0.tgz", + "integrity": "sha512-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/markdown-remark": "6.3.10", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", + "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tests/resources/sites/astro/src/pages/specs.js b/tests/resources/sites/astro/src/pages/specs.js new file mode 100644 index 0000000000..adc2d6768f --- /dev/null +++ b/tests/resources/sites/astro/src/pages/specs.js @@ -0,0 +1,13 @@ +export async function GET(_context) { + return new Response( + JSON.stringify({ + APPWRITE_SITE_MEMORY: process.env.APPWRITE_SITE_MEMORY, + APPWRITE_SITE_CPUS: process.env.APPWRITE_SITE_CPUS, + }), + { + headers: { + "Content-Type": "application/json", + }, + }, + ); +}