Compare commits

..
Author SHA1 Message Date
fogelito b46fc77053 paramQueries 2026-02-26 13:07:44 +02:00
57 changed files with 1170 additions and 2750 deletions
-4
View File
@@ -318,10 +318,6 @@ $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
+7
View File
@@ -2190,6 +2190,13 @@ 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,
+1 -1
View File
@@ -26,7 +26,7 @@ $console = [
'hostname' => 'localhost',
], // Current host is added on app init
],
'region' => System::getEnv('_APP_REGION', 'default'),
'region' => 'fra',
'legalName' => '',
'legalCountry' => '',
'legalState' => '',
-10
View File
@@ -630,11 +630,6 @@ return [
'description' => 'Site with the requested ID could not be found.',
'code' => 404,
],
Exception::SITE_ALREADY_EXISTS => [
'name' => Exception::SITE_ALREADY_EXISTS,
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::SITE_TEMPLATE_NOT_FOUND => [
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
'description' => 'Site Template with the requested ID could not be found.',
@@ -1296,11 +1291,6 @@ return [
'description' => 'Message with the requested ID could not be found.',
'code' => 404,
],
Exception::MESSAGE_ALREADY_EXISTS => [
'name' => Exception::MESSAGE_ALREADY_EXISTS,
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::MESSAGE_MISSING_TARGET => [
'name' => Exception::MESSAGE_MISSING_TARGET,
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
+1 -1
View File
@@ -188,7 +188,7 @@ return [
'name' => 'VCS',
'subtitle' => 'The VCS service allows you to interact with providers like GitHub, GitLab etc.',
'description' => '',
'controller' => '', // Uses modules
'controller' => 'api/vcs.php',
'sdk' => false,
'docs' => false,
'docsUrl' => '',
+6 -21
View File
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
}
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_EMAIL,
'topics' => $topics,
@@ -3267,12 +3267,7 @@ Http::post('/v1/messaging/messages/email')
'attachments' => $attachments,
],
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3405,7 +3400,7 @@ Http::post('/v1/messaging/messages/sms')
}
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_SMS,
'topics' => $topics,
@@ -3415,12 +3410,7 @@ Http::post('/v1/messaging/messages/sms')
'content' => $content,
],
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
@@ -3630,7 +3620,7 @@ Http::post('/v1/messaging/messages/push')
$pushData['priority'] = $priority;
}
$message = new Document([
$message = $dbForProject->createDocument('messages', new Document([
'$id' => $messageId,
'providerType' => MESSAGE_TYPE_PUSH,
'topics' => $topics,
@@ -3639,12 +3629,7 @@ Http::post('/v1/messaging/messages/push')
'scheduledAt' => $scheduledAt,
'data' => $pushData,
'status' => $status,
]);
try {
$message = $dbForProject->createDocument('messages', $message);
} catch (DuplicateException) {
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
}
]));
switch ($status) {
case MessageStatus::PROCESSING:
+9 -10
View File
@@ -73,9 +73,8 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/** TODO: Remove function when we move to using utopia/platform */
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
{
$name = $name ?? '';
$plaintextPassword = $password;
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
@@ -256,7 +255,7 @@ Http::post('/v1/users')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$plaintext = new Plaintext();
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
@@ -292,7 +291,7 @@ Http::post('/v1/users/bcrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
@@ -330,7 +329,7 @@ Http::post('/v1/users/md5')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$md5 = new MD5();
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -367,7 +366,7 @@ Http::post('/v1/users/argon2')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$argon2 = new Argon2();
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -405,7 +404,7 @@ Http::post('/v1/users/sha')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$sha = new Sha();
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
@@ -445,7 +444,7 @@ Http::post('/v1/users/phpass')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$phpass = new PHPass();
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
@@ -487,7 +486,7 @@ Http::post('/v1/users/scrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
@@ -533,7 +532,7 @@ Http::post('/v1/users/scrypt-modified')
->inject('project')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
+705
View File
@@ -0,0 +1,705 @@
<?php
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Appwrite\Vcs\Comment;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Http\Http;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
$createGitDeployments = function (GitHub $github, string $providerInstallationId, array $repositories, string $providerBranch, string $providerBranchUrl, string $providerRepositoryName, string $providerRepositoryUrl, string $providerRepositoryOwner, string $providerCommitHash, string $providerCommitAuthor, string $providerCommitAuthorUrl, string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, bool $external, Database $dbForPlatform, Authorization $authorization, Build $queueForBuilds, callable $getProjectDB, Request $request, array $platform) {
$errors = [];
foreach ($repositories as $repository) {
try {
$resourceType = $repository->getAttribute('resourceType');
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
}
$projectId = $repository->getAttribute('projectId');
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
$dbForProject = $getProjectDB($project);
$resourceCollection = $resourceType === "function" ? 'functions' : 'sites';
$resourceId = $repository->getAttribute('resourceId');
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
$resourceInternalId = $resource->getSequence();
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$installationId = $repository->getAttribute('installationId');
$installationInternalId = $repository->getAttribute('installationInternalId');
$productionBranch = $resource->getAttribute('providerBranch');
$activate = false;
if ($providerBranch == $productionBranch && $external === false) {
$activate = true;
}
$owner = $github->getOwnerName($providerInstallationId) ?? '';
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$isAuthorized = !$external;
if (!$isAuthorized && !empty($providerPullRequestId)) {
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
$isAuthorized = true;
}
}
$commentStatus = $isAuthorized ? 'waiting' : 'failed';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleHostname'] ?? '';
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
$latestCommentId = '';
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
$latestComment = $authorization->skip(fn () => $dbForPlatform->findOne('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerPullRequestId', [$providerPullRequestId]),
Query::orderDesc('$createdAt'),
]));
if (!$latestComment->isEmpty()) {
$latestCommentId = $latestComment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
} else {
$comment = new Comment($platform);
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
if (!empty($latestCommentId)) {
$teamId = $project->getAttribute('teamId', '');
$latestComment = $authorization->skip(fn () => $dbForPlatform->createDocument('vcsComments', new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'installationInternalId' => $installationInternalId,
'installationId' => $installationId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'providerRepositoryId' => $providerRepositoryId,
'providerBranch' => $providerBranch,
'providerPullRequestId' => $providerPullRequestId,
'providerCommentId' => $latestCommentId
])));
}
}
} elseif (!empty($providerBranch)) {
$latestComments = $authorization->skip(fn () => $dbForPlatform->find('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerBranch', [$providerBranch]),
Query::orderDesc('$createdAt'),
]));
foreach ($latestComments as $comment) {
$latestCommentId = $comment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
}
if (!$isAuthorized) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$name = "{$resourceName} ({$projectName})";
$message = 'Authorization required for external contributor.';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'failure', $message, $authorizeUrl, $name);
continue;
}
if ($external) {
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
$providerRepositoryName = $pullRequestResponse['head']['repo']['owner']['login'];
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['name'];
}
$commands = [];
if (!empty($resource->getAttribute('installCommand', ''))) {
$commands[] = $resource->getAttribute('installCommand', '');
}
if (!empty($resource->getAttribute('buildCommand', ''))) {
$commands[] = $resource->getAttribute('buildCommand', '');
}
if (!empty($resource->getAttribute('commands', ''))) {
$commands[] = $resource->getAttribute('commands', '');
}
$deployment = $authorization->skip(fn () => $dbForProject->createDocument('deployments', new Document([
'$id' => $deploymentId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'resourceType' => $resourceCollection,
'entrypoint' => $resource->getAttribute('entrypoint', ''),
'buildCommands' => \implode(' && ', $commands),
'startCommand' => $resource->getAttribute('startCommand', ''),
'buildOutput' => $resource->getAttribute('outputDirectory', ''),
'adapter' => $resource->getAttribute('adapter', ''),
'fallbackFile' => $resource->getAttribute('fallbackFile', ''),
'type' => 'vcs',
'installationId' => $installationId,
'installationInternalId' => $installationInternalId,
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => $repositoryId,
'repositoryInternalId' => $repositoryInternalId,
'providerBranchUrl' => $providerBranchUrl,
'providerRepositoryName' => $providerRepositoryName,
'providerRepositoryOwner' => $providerRepositoryOwner,
'providerRepositoryUrl' => $providerRepositoryUrl,
'providerCommitHash' => $providerCommitHash,
'providerCommitAuthorUrl' => $providerCommitAuthorUrl,
'providerCommitAuthor' => $providerCommitAuthor,
'providerCommitMessage' => mb_strimwidth($providerCommitMessage, 0, 255, '...'),
'providerCommitUrl' => $providerCommitUrl,
'providerCommentId' => \strval($latestCommentId),
'providerBranch' => $providerBranch,
'activate' => $activate,
])));
$resource = $resource
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), $resource));
if ($resource->getCollection() === 'sites') {
$projectId = $project->getId();
// Deployment preview
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$previewRuleId = $ruleId;
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
// VCS branch preview
if (!empty($providerBranch)) {
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
// VCS commit preview
if (!empty($providerCommitHash)) {
$domain = "commit-" . substr($providerCommitHash, 0, 16) . ".{$sitesDomain}";
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
}
if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
if (!empty($previewUrl)) {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl);
$github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment());
}
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$region = $project->getAttribute('region', 'default');
$name = "{$resourceName} ({$projectName})";
$message = 'Starting...';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId";
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name);
}
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
->setResource($resource)
->setDeployment($deployment)
->setProject($project); // set the project because it won't be set for git deployments
$queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
//TODO: Add event?
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
$queueForBuilds->reset(); // prevent shutdown hook from triggering again
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
};
Http::post('/v1/vcs/github/events')
->desc('Create event')
->groups(['api', 'vcs'])
->label('scope', 'public')
->inject('gitHub')
->inject('request')
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->action(
function (GitHub $github, Request $request, Response $response, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
$payload = $request->getRawPayload();
$signatureRemote = $request->getHeader('x-hub-signature-256', '');
$signatureLocal = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
$valid = empty($signatureRemote) ? true : $github->validateWebhookEvent($payload, $signatureRemote, $signatureLocal);
if (!$valid) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, "Invalid webhook payload signature. Please make sure the webhook secret has same value in your GitHub app and in the _APP_VCS_GITHUB_WEBHOOK_SECRET environment variable");
}
$event = $request->getHeader('x-github-event', '');
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$parsedPayload = $github->getEvent($event, $payload);
if ($event == $github::EVENT_PUSH) {
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? '';
$providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
$providerCommitMessage = $parsedPayload["headCommitMessage"] ?? '';
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
//find resourceId from relevant resources table
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::limit(100),
]));
// create new deployment only on push (not committed by us) and not when branch is created or deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
}
} elseif ($event == $github::EVENT_INSTALLATION) {
if ($parsedPayload["action"] == "deleted") {
// TODO: Use worker for this job instead (update function/site as well)
$providerInstallationId = $parsedPayload["installationId"];
$installations = $dbForPlatform->find('installations', [
Query::equal('providerInstallationId', [$providerInstallationId]),
Query::limit(1000)
]);
foreach ($installations as $installation) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('installationInternalId', [$installation->getSequence()]),
Query::limit(1000)
]));
foreach ($repositories as $repository) {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('repositories', $repository->getId()));
}
$authorization->skip(fn () => $dbForPlatform->deleteDocument('installations', $installation->getId()));
}
}
} elseif ($event == $github::EVENT_PULL_REQUEST) {
if ($parsedPayload["action"] == "opened" || $parsedPayload["action"] == "reopened" || $parsedPayload["action"] == "synchronize") {
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$external = $parsedPayload["external"] ?? true;
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
// Ignore sync for non-external. We handle it in push webhook
if (!$external && $parsedPayload["action"] == "synchronize") {
return $response->json($parsedPayload);
}
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
} elseif ($parsedPayload["action"] == "closed") {
// Allowed external contributions cleanup
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$external = $parsedPayload["external"] ?? true;
if ($external) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
foreach ($repositories as $repository) {
$providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []);
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));
}
}
}
}
}
$response->json($parsedPayload);
}
);
Http::patch('/v1/vcs/github/installations/:installationId/repositories/:repositoryId')
->desc('Update external deployment (authorize)')
->groups(['api', 'vcs'])
->label('scope', 'vcs.write')
->label('sdk', new Method(
namespace: 'vcs',
group: 'repositories',
name: 'updateExternalDeployments',
description: '/docs/references/vcs/update-external-deployments.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('repositoryId', '', new Text(256), 'VCS Repository Id')
->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id')
->inject('gitHub')
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->action(function (string $installationId, string $repositoryId, string $providerPullRequestId, GitHub $github, Request $request, Response $response, Document $project, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
$repository = $authorization->skip(fn () => $dbForPlatform->findOne('repositories', [
Query::equal('$id', [$repositoryId]),
Query::equal('projectInternalId', [$project->getSequence()])
]));
if ($repository->isEmpty()) {
throw new Exception(Exception::REPOSITORY_NOT_FOUND);
}
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
throw new Exception(Exception::PROVIDER_CONTRIBUTION_CONFLICT);
}
$providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId]));
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
// TODO: Delete from array when PR is closed
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$providerInstallationId = $installation->getAttribute('providerInstallationId');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$repositories = [$repository];
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$owner = $github->getOwnerName($providerInstallationId);
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
$providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? '';
$providerCommitHash = $pullRequestResponse['head']['sha'] ?? '';
$providerBranchUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryName = $pullRequestResponse['head']['repo']['name'] ?? '';
$providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? '';
$providerCommitAuthor = $pullRequestResponse['head']['user']['login'] ?? '';
$providerCommitAuthorUrl = $pullRequestResponse['head']['user']['html_url'] ?? '';
$providerCommitMessage = $pullRequestResponse['title'] ?? '';
$providerCommitUrl = $pullRequestResponse['html_url'] ?? '';
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, '', '', '', '', $providerCommitHash, '', '', '', '', $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
$response->noContent();
});
+109 -40
View File
@@ -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;
@@ -35,7 +35,6 @@ 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;
@@ -63,7 +62,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, Bus $bus, 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, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname() ?? '';
if (!empty($previewHostname)) {
@@ -707,12 +706,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
} finally {
if ($type === 'function' || $type === 'site') {
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
spec: $spec,
resource: $resource->getArrayCopy(),
));
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
}
}
@@ -757,12 +754,70 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
->setStatusCode($execution['responseStatusCode'] ?? 200)
->send($body);
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
deployment: $deployment->getArrayCopy(),
));
$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();
/* cleanup */
if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
@@ -826,8 +881,9 @@ Http::init()
->inject('locale')
->inject('localeCodes')
->inject('geodb')
->inject('queueForStatsUsage')
->inject('queueForEvents')
->inject('bus')
->inject('queueForExecutions')
->inject('executor')
->inject('platform')
->inject('isResourceBlocked')
@@ -838,7 +894,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, Event $queueForEvents, Bus $bus, 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, 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) {
/*
* Appwrite Router
*/
@@ -846,7 +902,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, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1122,7 +1178,8 @@ Http::options()
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('bus')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1135,14 +1192,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, 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) {
->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) {
/*
* 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, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1158,11 +1215,12 @@ 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
*/
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
$queueForStatsUsage
->addMetric(METRIC_NETWORK_REQUESTS, 1)
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize())
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize())
->setProject($project)
->trigger();
});
Http::error()
@@ -1173,10 +1231,10 @@ Http::error()
->inject('project')
->inject('logger')
->inject('log')
->inject('bus')
->inject('queueForStatsUsage')
->inject('devKey')
->inject('authorization')
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage, Document $devKey, Authorization $authorization) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$class = \get_class($error);
@@ -1249,12 +1307,21 @@ Http::error()
*/
if (!$publish && $project->getId() !== 'console') {
if (!DBUser::isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
$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());
}
$queueForStatsUsage
->setProject($project)
->trigger();
}
if ($logger && $publish) {
@@ -1501,7 +1568,8 @@ Http::get('/robots.txt')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('bus')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1511,13 +1579,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, Bus $bus, 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, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, 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, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1535,7 +1603,8 @@ Http::get('/humans.txt')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('bus')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1545,13 +1614,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, Bus $bus, 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, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, 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, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
+19 -23
View File
@@ -2,7 +2,6 @@
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;
@@ -22,14 +21,12 @@ 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;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
@@ -748,8 +745,7 @@ Http::shutdown()
->inject('authorization')
->inject('timelimit')
->inject('eventProcessor')
->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) {
->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) {
$responsePayload = $response->getPayload();
@@ -933,19 +929,14 @@ Http::shutdown()
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
try {
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} catch (DuplicateException) {
// Race condition: another concurrent request already created the cache document
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
}
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'resourceType' => $resourceType,
'mimeType' => $response->getContentType(),
'accessedAt' => $now,
'signature' => $signature,
])));
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$cacheLog->setAttribute('accessedAt', $now);
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
@@ -961,11 +952,16 @@ Http::shutdown()
if ($project->getId() !== 'console') {
if (!User::isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
response: $response,
));
$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());
}
$queueForStatsUsage
+5 -4
View File
@@ -188,10 +188,6 @@ $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
@@ -566,6 +562,11 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
$log->addTag('method', $route?->getMethod() ?? $request->getMethod());
$log->addTag('url', $route?->getPath() ?? $request->getURI());
if (str_contains($th->getMessage(), 'FTS_TERM or FTS_NUMB')) {
$log->addTag('paramQueries', json_encode($request->getParam('queries')));
}
$log->addTag('verboseType', get_class($th));
$log->addTag('code', $th->getCode());
// $log->addTag('projectId', $project->getId()); // TODO: Figure out how to get ProjectID, if it becomes relevant
-8
View File
@@ -449,11 +449,3 @@ $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;
});
+4
View File
@@ -10,6 +10,7 @@ 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;
@@ -159,6 +160,9 @@ 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();
}, []);
-9
View File
@@ -1,9 +0,0 @@
<?php
use Appwrite\Bus\Listeners\Log;
use Appwrite\Bus\Listeners\Usage;
return [
new Log(),
new Usage(),
];
+41 -69
View File
@@ -248,62 +248,48 @@ $adapter
$server = new Server($adapter);
// Allows overriding
if (!function_exists('logError')) {
function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void
{
global $register;
$logError = function (Throwable $error, string $action) use ($register) {
$logger = $register->get('realtimeLogger');
$logger = $register->get('realtimeLogger');
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
if ($logger && !$error instanceof Exception) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log = new Log();
$log->setNamespace("realtime");
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$log->setVersion($version);
$log->setType(Log::TYPE_ERROR);
$log->setMessage($error->getMessage());
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log->addTag('code', $error->getCode());
$log->addTag('verboseType', get_class($error));
$log->addTag('projectId', $project?->getId() ?: 'n/a');
$log->addTag('userId', $user?->getId() ?: 'n/a');
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
foreach ($tags as $key => $value) {
$log->addTag($key, $value ?: 'n/a');
}
$log->setAction($action);
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
$log->addExtra('detailedTrace', $error->getTrace());
$log->addExtra('roles', $authorization?->getRoles() ?? []);
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
$log->setAction($action);
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
try {
$responseCode = $logger->addLog($log);
Console::info('Error log pushed with status code: ' . $responseCode);
} catch (Throwable $th) {
Console::error('Error pushing log: ' . $th->getMessage());
}
try {
$responseCode = $logger->addLog($log);
Console::info('Error log pushed with status code: ' . $responseCode);
} catch (Throwable $th) {
Console::error('Error pushing log: ' . $th->getMessage());
}
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
}
}
$server->error(logError(...));
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $error->getMessage());
Console::error('[Error] File: ' . $error->getFile());
Console::error('[Error] Line: ' . $error->getLine());
};
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
$server->error($logError);
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) {
sleep(5); // wait for the initial database schema to be ready
Console::success('Server started successfully');
@@ -340,7 +326,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
*/
// TODO: Remove this if check once it doesn't cause issues for cloud
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
Timer::tick(5000, function () use ($register, $stats, &$statsDocument, $logError) {
$payload = [];
foreach ($stats as $projectId => $value) {
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
@@ -358,13 +344,13 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument));
} catch (Throwable $th) {
logError($th, "updateWorkerDocument");
$logError($th, "updateWorkerDocument");
}
});
}
});
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) {
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
@@ -376,7 +362,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$attempts = 0;
$start = time();
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
@@ -562,7 +548,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
}
});
} catch (Throwable $th) {
logError($th, "pubSubConnection");
$logError($th, "pubSubConnection");
Console::error('Pub/sub error: ' . $th->getMessage());
$attempts++;
@@ -574,7 +560,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $logError) {
$app = new Http('UTC');
$request = new Request($request);
$response = new Response(new SwooleResponse());
@@ -585,10 +571,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
Http::setResource('request', fn () => $request);
Http::setResource('response', fn () => $response);
$project = null;
$logUser = null;
$authorization = null;
try {
/** @var Document $project */
$project = $app->getResource('project');
@@ -609,15 +591,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
$projectRegion = $project->getAttribute('region', '');
$currentRegion = System::getEnv('_APP_REGION', 'default');
if (!empty($projectRegion) && $projectRegion !== $currentRegion) {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
}
$timelimit = $app->getResource('timelimit');
$user = $app->getResource('user'); /** @var User $user */
$logUser = $user;
/*
* Abuse Check
@@ -708,11 +683,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$stats->incr($project->getId(), 'connections');
$stats->incr($project->getId(), 'connectionsTotal');
} catch (Throwable $th) {
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
$logError($th, "initServer");
// Handle SQL error code is 'HY000'
$code = $th->getCode();
if (!\is_int($code)) {
if (!is_int($code)) {
$code = 500;
}
@@ -743,10 +718,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
$project = null;
$authorization = null;
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) {
try {
$response = new Response(new SwooleResponse());
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
@@ -872,7 +844,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
} catch (Throwable $th) {
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$logError($th, "realtimeMessage");
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
+4 -4
View File
@@ -9,6 +9,7 @@ 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;
@@ -354,6 +355,9 @@ 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();
@@ -538,10 +542,6 @@ 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')
+1 -2
View File
@@ -19,8 +19,7 @@
"autoload": {
"psr-4": {
"Appwrite\\": "src/Appwrite",
"Executor\\": "src/Executor",
"Utopia\\Bus\\": "src/Utopia/Bus"
"Executor\\": "src/Executor"
}
},
"autoload-dev": {
Generated
+36 -36
View File
@@ -4517,16 +4517,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.6.2",
"version": "1.6.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca"
"reference": "c5c7544d02d2418536d41050794050132f247d62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/037bf4b3813d44f1b0990bc124e35b501ed27fca",
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c5c7544d02d2418536d41050794050132f247d62",
"reference": "c5c7544d02d2418536d41050794050132f247d62",
"shasum": ""
},
"require": {
@@ -4566,9 +4566,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.6.2"
"source": "https://github.com/utopia-php/migration/tree/1.6.1"
},
"time": "2026-02-25T12:00:11+00:00"
"time": "2026-02-17T05:49:48+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4684,16 +4684,16 @@
},
{
"name": "utopia-php/pools",
"version": "1.0.3",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/pools.git",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
"url": "https://api.github.com/repos/utopia-php/pools/zipball/b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
"shasum": ""
},
"require": {
@@ -4731,9 +4731,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/pools/issues",
"source": "https://github.com/utopia-php/pools/tree/1.0.3"
"source": "https://github.com/utopia-php/pools/tree/1.0.2"
},
"time": "2026-02-26T08:42:40+00:00"
"time": "2026-01-28T13:12:36+00:00"
},
{
"name": "utopia-php/preloader",
@@ -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,7 @@
"type": "paypal"
}
],
"time": "2026-02-25T14:53:45+00:00"
"time": "2026-02-06T10:53:26+00:00"
},
{
"name": "doctrine/annotations",
@@ -9043,7 +9043,7 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
-4
View File
@@ -1,11 +1,7 @@
parameters:
level: 8
paths:
- src/Utopia/Bus
- src/Appwrite/Bus
- src/Appwrite/Transformation
bootstrapFiles:
- app/init/constants.php
scanDirectories:
- vendor/swoole/ide-helper
excludePaths:
@@ -1,22 +0,0 @@
<?php
namespace Appwrite\Bus\Events;
use Utopia\Bus\Event;
class ExecutionCompleted implements Event
{
/**
* @param array<string, mixed> $execution
* @param array<string, mixed> $project
* @param array<string, mixed> $spec
* @param array<string, mixed> $resource
*/
public function __construct(
public readonly array $execution,
public readonly array $project,
public readonly array $spec = [],
public readonly array $resource = [],
) {
}
}
@@ -1,22 +0,0 @@
<?php
namespace Appwrite\Bus\Events;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Bus\Event;
class RequestCompleted implements Event
{
/**
* @param array<string, mixed> $project
* @param array<string, mixed> $deployment
*/
public function __construct(
public readonly array $project,
public readonly Request $request,
public readonly Response $response,
public readonly array $deployment = [],
) {
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Event\Execution;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Log extends Listener
{
public static function getName(): string
{
return 'log';
}
public static function getEvents(): array
{
return [ExecutionCompleted::class];
}
public function __construct()
{
$this
->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();
}
}
-114
View File
@@ -1,114 +0,0 @@
<?php
namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
use Appwrite\Event\StatsUsage;
use Utopia\Bus\Event;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Usage extends Listener
{
public static function getName(): string
{
return 'usage';
}
public static function getEvents(): array
{
return [
ExecutionCompleted::class,
RequestCompleted::class,
];
}
public function __construct()
{
$this
->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();
}
}
+1 -3
View File
@@ -725,9 +725,7 @@ class Event
$events = $pairedEvents;
}
// mirrored events can have duplicates in case of smaller events
// array unique can turns list to hasmap in case duplicates present
// so forcing array value will turn this to array list always
return array_values(array_unique($events));
return array_unique($events);
}
/**
-2
View File
@@ -166,7 +166,6 @@ class Exception extends \Exception
/** Sites */
public const string SITE_NOT_FOUND = 'site_not_found';
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
/** Functions */
@@ -366,7 +365,6 @@ class Exception extends \Exception
/** Message */
public const string MESSAGE_NOT_FOUND = 'message_not_found';
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
+18 -7
View File
@@ -510,13 +510,25 @@ class Realtime extends MessagingAdapter
$collectionId = $payload->getAttribute('$collectionId', '');
$resourceId = $tableId ?: $collectionId;
$channels = [];
// backward compat(tablesdb will have databases channels + tablesdb prefixed channels)
if ($parts[0] === 'databases' || $parts[0] === 'tablesdb') {
$prefix = 'databases';
// 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())
)));
$channels = self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), $prefix);
$channels = array_unique([
...$channels,
...self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), $prefix)
]);
}
// prefixed channels -> tablesdb
if ($parts[0] !== 'databases') {
$channels = array_unique([
...$channels,
...self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId()),
]);
}
$roles = $collection->getAttribute('documentSecurity', false)
? \array_merge($collection->getRead(), $payload->getRead())
@@ -608,7 +620,6 @@ class Realtime extends MessagingAdapter
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
break;
case 'tablesdb':
$channels[] = 'rows';
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows";
@@ -24,7 +24,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends Action
@@ -71,17 +70,15 @@ class XList extends Action
->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, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->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('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, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
@@ -132,73 +129,9 @@ class XList extends Action
$documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries);
$total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
} elseif (! empty($selectQueries)) {
if ((int)$ttl > 0) {
$serializedQueries = [];
foreach ($queries as $query) {
$serializedQueries[] = $query instanceof Query ? $query->toArray() : $query;
}
$hostname = $dbForProject->getAdapter()->getHostname();
$roles = $dbForProject->getAuthorization()->getRoles();
$schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])));
$cacheKeyBase = \sprintf(
'%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
$dbForProject->getCacheName(),
$hostname ?? '',
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$collectionId,
$schemaHash,
\md5(\json_encode($roles)),
\md5(\json_encode($serializedQueries))
);
$documentsCacheKey = $cacheKeyBase . ':documents';
$totalCacheKey = $cacheKeyBase . ':total';
$documentsCacheHit = $totalDocumentsCacheHit = false;
$cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl);
if ($cachedDocuments !== null &&
$cachedDocuments !== false &&
\is_array($cachedDocuments)) {
$documents = \array_map(function ($doc) {
return new Document($doc);
}, $cachedDocuments);
$documentsCacheHit = true;
} else {
$documents = $dbForProject->find($collectionTableId, $queries);
// Convert Document objects to arrays for caching
$documentsArray = \array_map(function ($doc) {
return $doc->getArrayCopy();
}, $documents);
$dbForProject->getCache()->save($documentsCacheKey, $documentsArray);
}
if ($includeTotal) {
$cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl);
if ($cachedTotal !== null && $cachedTotal !== false) {
$total = $cachedTotal;
$totalDocumentsCacheHit = true;
} else {
$total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT);
$dbForProject->getCache()->save($totalCacheKey, $total);
}
} else {
$total = 0;
}
$response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
} else {
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
// has selects, allow relationship on documents
$documents = $dbForProject->find($collectionTableId, $queries);
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
} else {
// has no selects, disable relationship loading on documents
/* @type Document[] $documents */
@@ -14,7 +14,6 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class XList extends DocumentXList
@@ -57,10 +56,8 @@ class XList extends DocumentXList
->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, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->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('queueForStatsUsage')
->inject('transactionState')
->inject('authorization')
@@ -90,15 +90,6 @@ class Delete extends Base
}
$status = $execution->getAttribute('status');
// Treat timed-out executions as failed so they can be deleted.
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$status = 'failed';
}
}
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
}
@@ -82,16 +82,6 @@ class Get extends Base
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
// Override status in response if the execution is stuck in waiting/processing beyond the function timeout.
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $function->getAttribute('timeout', 900);
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
$response->dynamic($execution, Response::MODEL_EXECUTION);
}
}
@@ -11,7 +11,6 @@ use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -111,35 +110,6 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing execution is considered timed out.
$timeout = $function->getAttribute('timeout', 900);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing executions created before the timeout threshold, so timed-out
// executions that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -149,20 +119,6 @@ class XList extends Base
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
// Override status in response for timed-out executions, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $execution) {
$status = $execution->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($execution->getCreatedAt());
if ($elapsed >= $timeout) {
$execution->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -71,16 +71,6 @@ class Get extends Base
throw new Exception(Exception::LOG_NOT_FOUND);
}
// Override status in response if the log is stuck in waiting/processing beyond the site timeout.
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$timeout = $site->getAttribute('timeout', 30);
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
}
}
@@ -11,7 +11,6 @@ use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Database\Validator\Queries\Logs;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -100,35 +99,6 @@ class XList extends Base
$cursor->setValue($cursorDocument);
}
// Calculate the cutoff datetime before which a waiting/processing log is considered timed out.
$timeout = $site->getAttribute('timeout', 30);
$thresholdDate = new \DateTime("-{$timeout} seconds");
$threshold = DateTime::format($thresholdDate);
// Capture what statuses the caller explicitly requested, before we mutate the query.
$requestedStatuses = [];
foreach ($queries as $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
}
}
// If the caller is filtering by 'failed', expand the DB query to also return
// waiting/processing logs created before the timeout threshold, so timed-out
// logs that were never marked failed in the DB are included in the results.
foreach ($queries as $index => $query) {
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
$queries[$index] = Query::or([
$query,
Query::and([
Query::equal('status', ['waiting', 'processing']),
Query::createdBefore($threshold),
]),
]);
break;
}
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
@@ -138,20 +108,6 @@ class XList extends Base
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
// Override status in response for timed-out logs, but only when the caller
// did not explicitly request a non-failed status (e.g. waiting/processing).
if (empty(\array_diff($requestedStatuses, ['failed']))) {
foreach ($results as $log) {
$status = $log->getAttribute('status', '');
if ($status === 'waiting' || $status === 'processing') {
$elapsed = \time() - \strtotime($log->getCreatedAt());
if ($elapsed >= $timeout) {
$log->setAttribute('status', 'failed');
}
}
}
}
$response->dynamic(new Document([
'executions' => $results,
'total' => $total,
@@ -14,7 +14,6 @@ use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -137,7 +136,7 @@ class Create extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
$site = new Document([
$site = $dbForProject->createDocument('sites', new Document([
'$id' => $siteId,
'enabled' => $enabled,
'live' => true,
@@ -167,17 +166,13 @@ class Create extends Base
'runtimeSpecification' => $specification,
'buildRuntime' => $buildRuntime,
'adapter' => $adapter,
]);
try {
$site = $dbForProject->createDocument('sites', $site);
} catch (DuplicateException) {
throw new Exception(Exception::SITE_ALREADY_EXISTS);
}
]));
// Git connect logic
if (!empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = new Document([
$repository = $dbForPlatform->createDocument('repositories', new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -189,8 +184,8 @@ class Create extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
]));
$site->setAttribute('repositoryId', $repository->getId());
$site->setAttribute('repositoryInternalId', $repository->getSequence());
}
@@ -190,9 +190,11 @@ class Update extends Base
$repositoryInternalId = '';
}
// Git connect logic
if (!$isConnected && !empty($providerRepositoryId)) {
$teamId = $project->getAttribute('teamId', '');
$repository = new Document([
$repository = $dbForPlatform->createDocument('repositories', new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $project->getId()),
'installationId' => $installation->getId(),
@@ -204,8 +206,8 @@ class Update extends Base
'resourceInternalId' => $site->getSequence(),
'resourceType' => 'site',
'providerPullRequestIds' => []
]);
$repository = $dbForPlatform->createDocument('repositories', $repository);
]));
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
}
@@ -1,141 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
use Appwrite\SDK\AuthType;
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\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
class Update extends Action
{
use HTTP;
use Deployment;
public static function getName()
{
return 'updateExternalDeployment';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/vcs/github/installations/:installationId/repositories/:repositoryId')
->desc('Update external deployment (authorize)')
->groups(['api', 'vcs'])
->label('scope', 'vcs.write')
->label('sdk', new Method(
namespace: 'vcs',
group: 'repositories',
name: 'updateExternalDeployments',
description: '/docs/references/vcs/update-external-deployments.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('repositoryId', '', new Text(256), 'VCS Repository Id')
->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id')
->inject('gitHub')
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->callback($this->action(...));
}
public function action(
string $installationId,
string $repositoryId,
string $providerPullRequestId,
GitHub $github,
Response $response,
Document $project,
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
Build $queueForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
$repository = $authorization->skip(fn () => $dbForPlatform->findOne('repositories', [
Query::equal('$id', [$repositoryId]),
Query::equal('projectInternalId', [$project->getSequence()])
]));
if ($repository->isEmpty()) {
throw new Exception(Exception::REPOSITORY_NOT_FOUND);
}
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
throw new Exception(Exception::PROVIDER_CONTRIBUTION_CONFLICT);
}
$providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId]));
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$providerInstallationId = $installation->getAttribute('providerInstallationId');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$repositories = [$repository];
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$providerRepositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($providerRepositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$pullRequestResponse = $github->getPullRequest($owner, $providerRepositoryName, $providerPullRequestId);
$providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? '';
$providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? '';
$providerBranchUrl = "$providerRepositoryUrl/tree/$providerBranch";
$providerCommitHash = $pullRequestResponse['head']['sha'] ?? '';
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$providerCommitUrl = $commitDetails["commitUrl"] ?? '';
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
$response->noContent();
}
}
@@ -4,12 +4,13 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Callback;
use Appwrite\Auth\OAuth2\Github as OAuth2Github;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Permission as AppwritePermission;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
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\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -20,7 +21,6 @@ use Utopia\VCS\Adapter\Git\GitHub;
class Get extends Action
{
use HTTP;
use AppwritePermission;
public static function getName()
{
@@ -132,7 +132,13 @@ class Get extends Action
$installation = new Document([
'$id' => ID::unique(),
'$permissions' => $this->getPermissions($teamId, $projectId),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'providerInstallationId' => $providerInstallationId,
'projectId' => $projectId,
'projectInternalId' => $projectInternalId,
@@ -1,523 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
use Appwrite\Event\Build;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
trait Deployment
{
protected function createGitDeployments(
GitHub $github,
string $providerInstallationId,
array $repositories,
string $providerBranch,
string $providerBranchUrl,
string $providerRepositoryName,
string $providerRepositoryUrl,
string $providerRepositoryOwner,
string $providerCommitHash,
string $providerCommitAuthor,
string $providerCommitAuthorUrl,
string $providerCommitMessage,
string $providerCommitUrl,
string $providerPullRequestId,
bool $external,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$errors = [];
foreach ($repositories as $repository) {
try {
$repositoryId = $repository->getId();
$projectId = $repository->getAttribute('projectId');
$resourceId = $repository->getAttribute('resourceId');
$resourceType = $repository->getAttribute('resourceType');
$logBase = "vcs.github.event.repo.{$repositoryId}";
Span::add("{$logBase}.projectId", $projectId);
Span::add("{$logBase}.resourceId", $resourceId);
Span::add("{$logBase}.resourceType", $resourceType);
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
}
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
try {
$dsn = new DSN($project->getAttribute('database'));
$databaseName = $dsn->getHost();
} catch (\InvalidArgumentException) {
$databaseName = $project->getAttribute('database');
}
$databases = Config::getParam('pools-database', []);
$index = in_array($databaseName, $databases);
if ($index === false) {
Console::error("Database: '{$databaseName}' is not part of region: " . System::getEnv('_APP_REGION'));
continue;
}
$dbForProject = $getProjectDB($project);
$resourceCollection = $resourceType === "function" ? 'functions' : 'sites';
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
$resourceInternalId = $resource->getSequence();
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
$installationId = $repository->getAttribute('installationId');
$installationInternalId = $repository->getAttribute('installationInternalId');
$productionBranch = $resource->getAttribute('providerBranch');
$activate = false;
if ($providerBranch == $productionBranch && $external === false) {
$activate = true;
}
$owner = $github->getOwnerName($providerInstallationId) ?? '';
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$isAuthorized = !$external;
if (!$isAuthorized && !empty($providerPullRequestId)) {
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
$isAuthorized = true;
}
}
Span::add("{$logBase}.authorized", $isAuthorized);
$commentStatus = 'waiting';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleHostname'] ?? '';
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
$latestCommentId = '';
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
$latestComment = $authorization->skip(fn () => $dbForPlatform->findOne('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerPullRequestId', [$providerPullRequestId]),
Query::orderDesc('$createdAt'),
]));
if (!$latestComment->isEmpty()) {
$latestCommentId = $latestComment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
} else {
$comment = new Comment($platform);
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
if (!empty($latestCommentId)) {
$teamId = $project->getAttribute('teamId', '');
$latestComment = $authorization->skip(fn () => $dbForPlatform->createDocument('vcsComments', new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
Permission::update(Role::team(ID::custom($teamId), 'owner')),
Permission::update(Role::team(ID::custom($teamId), 'developer')),
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
],
'installationInternalId' => $installationInternalId,
'installationId' => $installationId,
'projectInternalId' => $project->getSequence(),
'projectId' => $project->getId(),
'providerRepositoryId' => $providerRepositoryId,
'providerBranch' => $providerBranch,
'providerPullRequestId' => $providerPullRequestId,
'providerCommentId' => $latestCommentId
])));
}
}
} elseif (!empty($providerBranch)) {
$latestComments = $authorization->skip(fn () => $dbForPlatform->find('vcsComments', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::equal('providerBranch', [$providerBranch]),
Query::orderDesc('$createdAt'),
]));
foreach ($latestComments as $comment) {
$latestCommentId = $comment->getAttribute('providerCommentId', '');
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
}
if (!$isAuthorized) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$name = "{$resourceName} ({$projectName})";
$message = 'Authorization required for external contributor.';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $authorizeUrl, $name);
continue;
}
$commands = [];
if (!empty($resource->getAttribute('installCommand', ''))) {
$commands[] = $resource->getAttribute('installCommand', '');
}
if (!empty($resource->getAttribute('buildCommand', ''))) {
$commands[] = $resource->getAttribute('buildCommand', '');
}
if (!empty($resource->getAttribute('commands', ''))) {
$commands[] = $resource->getAttribute('commands', '');
}
$deployment = $authorization->skip(fn () => $dbForProject->createDocument('deployments', new Document([
'$id' => $deploymentId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'resourceId' => $resourceId,
'resourceInternalId' => $resourceInternalId,
'resourceType' => $resourceCollection,
'entrypoint' => $resource->getAttribute('entrypoint', ''),
'buildCommands' => \implode(' && ', $commands),
'startCommand' => $resource->getAttribute('startCommand', ''),
'buildOutput' => $resource->getAttribute('outputDirectory', ''),
'adapter' => $resource->getAttribute('adapter', ''),
'fallbackFile' => $resource->getAttribute('fallbackFile', ''),
'type' => 'vcs',
'installationId' => $installationId,
'installationInternalId' => $installationInternalId,
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => $repositoryId,
'repositoryInternalId' => $repositoryInternalId,
'providerBranchUrl' => $providerBranchUrl,
'providerRepositoryName' => $providerRepositoryName,
'providerRepositoryOwner' => $providerRepositoryOwner,
'providerRepositoryUrl' => $providerRepositoryUrl,
'providerCommitHash' => $providerCommitHash,
'providerCommitAuthorUrl' => $providerCommitAuthorUrl,
'providerCommitAuthor' => $providerCommitAuthor,
'providerCommitMessage' => mb_strimwidth($providerCommitMessage, 0, 255, '...'),
'providerCommitUrl' => $providerCommitUrl,
'providerCommentId' => \strval($latestCommentId),
'providerBranch' => $providerBranch,
'activate' => $activate,
])));
$resource = $resource
->setAttribute('latestDeploymentId', $deployment->getId())
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
$authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), $resource));
if ($resource->getCollection() === 'sites') {
$projectId = $project->getId();
// Deployment preview
$sitesDomain = $platform['sitesDomain'];
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$previewRuleId = $ruleId;
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
// VCS branch preview
if (!empty($providerBranch)) {
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
// VCS commit preview
if (!empty($providerCommitHash)) {
$domain = "commit-" . substr($providerCommitHash, 0, 16) . ".{$sitesDomain}";
$ruleId = md5($domain);
try {
$authorization->skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'type' => 'deployment',
'trigger' => 'deployment',
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'site',
'deploymentResourceId' => $resourceId,
'deploymentResourceInternalId' => $resourceInternalId,
'deploymentVcsProviderBranch' => $providerBranch,
'status' => 'verified',
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => 'Appwrite',
'region' => $project->getAttribute('region')
]))
);
} catch (Duplicate $err) {
// Ignore, rule already exists; will be updated by builds worker
}
}
}
if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
$retries = 0;
$lockAcquired = false;
while ($retries < 9) {
$retries++;
try {
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
'$id' => $latestCommentId
]));
$lockAcquired = true;
break;
} catch (\Throwable $err) {
if ($retries >= 9) {
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
}
\sleep(1);
}
}
if ($lockAcquired) {
// Wrap in try/finally to ensure lock file gets deleted
try {
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
if (!empty($previewUrl)) {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl);
$github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment());
}
} finally {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
}
}
}
if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) {
$resourceName = $resource->getAttribute('name');
$projectName = $project->getAttribute('name');
$region = $project->getAttribute('region', 'default');
$name = "{$resourceName} ({$projectName})";
$message = 'Starting...';
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$owner = $github->getOwnerName($providerInstallationId);
$providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId";
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name);
}
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
$queueForBuilds
->setQueue($queueName)
->setType(BUILD_TYPE_DEPLOYMENT)
->setResource($resource)
->setDeployment($deployment)
->setProject($project); // set the project because it won't be set for git deployments
$queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
} catch (\Throwable $e) {
Span::add("{$logBase}.error", $e->getMessage());
$errors[] = $e->getMessage();
}
}
$queueForBuilds->reset(); // prevent shutdown hook from triggering again
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
}
protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string
{
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
}
}
@@ -1,244 +0,0 @@
<?php
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\GitHub;
class Create extends Action
{
use HTTP;
use Deployment;
public static function getName()
{
return 'createVCSGitHubEvent';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/vcs/github/events')
->desc('Create event')
->groups(['api', 'vcs'])
->label('scope', 'public')
->inject('gitHub')
->inject('request')
->inject('response')
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
->inject('queueForBuilds')
->inject('platform')
->callback($this->action(...));
}
public function action(
GitHub $github,
Request $request,
Response $response,
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
Build $queueForBuilds,
array $platform
) {
$this->preprocessEvent($request);
$event = $request->getHeader('x-github-event', '');
Span::add('vcs.github.event.name', $event);
$payload = $request->getRawPayload();
$signature = $request->getHeader('x-hub-signature-256', '');
$secretKey = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
$valid = empty($signature) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey);
Span::add('vcs.github.event.signature.valid', $valid);
if (!$valid) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, "Invalid webhook payload signature. Please make sure the webhook secret has same value in your GitHub app and in the _APP_VCS_GITHUB_WEBHOOK_SECRET environment variable");
}
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$parsedPayload = $github->getEvent($event, $payload);
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
$github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
$github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
default => null,
};
return $response->json($parsedPayload);
}
protected function preprocessEvent(Request $request)
{
return;
}
private function handleInstallationEvent(
array $parsedPayload,
Database $dbForPlatform,
Authorization $authorization,
) {
if ($parsedPayload["action"] !== "deleted") {
return;
}
// TODO: Use worker for this job instead (update function/site as well)
$providerInstallationId = $parsedPayload["installationId"];
$installations = $dbForPlatform->find('installations', [
Query::equal('providerInstallationId', [$providerInstallationId]),
Query::limit(1000)
]);
foreach ($installations as $installation) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('installationInternalId', [$installation->getSequence()]),
Query::limit(1000)
]));
foreach ($repositories as $repository) {
$authorization->skip(fn () => $dbForPlatform->deleteDocument('repositories', $repository->getId()));
}
$authorization->skip(fn () => $dbForPlatform->deleteDocument('installations', $installation->getId()));
}
}
private function handlePushEvent(
array $parsedPayload,
string $githubAppId,
string $privateKey,
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? '';
$providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
$providerCommitMessage = $parsedPayload["headCommitMessage"] ?? '';
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
Span::add('vcs.github.event.repo.id', $providerRepositoryId);
Span::add('vcs.github.event.repo.name', $providerRepositoryName);
Span::add('vcs.github.event.branch', $providerBranch);
Span::add('vcs.github.event.installation.id', $providerInstallationId);
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
// Find associated repositories
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::limit(100),
]));
// Create new deployment only on push (not committed by us) and not when branch is created or deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
}
}
private function handlePullRequestEvent(
array $parsedPayload,
string $privateKey,
string $githubAppId,
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
Build $queueForBuilds,
callable $getProjectDB,
array $platform,
) {
$action = $parsedPayload["action"] ?? '';
if ($action == "opened" || $action == "reopened" || $action == "synchronize") {
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
$providerInstallationId = $parsedPayload["installationId"] ?? '';
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
$external = $parsedPayload["external"] ?? true;
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
Span::add('vcs.github.event.repo.id', $providerRepositoryId);
Span::add('vcs.github.event.repo.name', $providerRepositoryName);
Span::add('vcs.github.event.branch', $providerBranch);
Span::add('vcs.github.event.installation.id', $providerInstallationId);
// Ignore sync for non-external. We handle it in push webhook
if (!$external && $parsedPayload["action"] == "synchronize") {
return;
}
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
$external = $parsedPayload["external"] ?? true;
if ($external) {
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
foreach ($repositories as $repository) {
$providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []);
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));
}
}
}
}
}
}
@@ -152,8 +152,6 @@ class Create extends Action
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
$repository['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
$repository['authorized'] = true;
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
}
@@ -85,23 +85,11 @@ class Get extends Action
$repository = $github->getRepository($owner, $repositoryName);
$authorized = false;
try {
$installationRepository = $github->getInstallationRepository($repositoryName);
if (!empty($installationRepository)) {
$authorized = true;
}
} catch (RepositoryNotFound $e) {
$authorized = false;
}
$repository['id'] = \strval($repository['id']) ?? '';
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
$repository['defaultBranch'] = $repository['default_branch'] ?? '';
$repository['authorized'] = $authorized;
$repository['providerInstallationId'] = $providerInstallationId;
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
}
@@ -148,8 +148,6 @@ class XList extends Action
$repo['pushedAt'] = $repo['pushed_at'] ?? null;
$repo['provider'] = $installation->getAttribute('provider', '') ?? '';
$repo['organization'] = $installation->getAttribute('organization', '') ?? '';
$repo['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
$repo['authorized'] = true;
return $repo;
}, $repos);
@@ -2,10 +2,8 @@
namespace Appwrite\Platform\Modules\VCS\Services;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External\Update as UpdateExternalDeployment;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\Get as GetGitHubAuthorize;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Callback\Get as GetGitHubCallback;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Events\Create as CreateGitHubEvent;
use Appwrite\Platform\Modules\VCS\Http\Installations\Delete as DeleteInstallation;
use Appwrite\Platform\Modules\VCS\Http\Installations\Get as GetInstallation;
use Appwrite\Platform\Modules\VCS\Http\Installations\Repositories\Branches\XList as ListRepositoryBranches;
@@ -26,7 +24,6 @@ class Http extends Service
// GitHub Authorization & Callback
$this->addAction(GetGitHubAuthorize::getName(), new GetGitHubAuthorize());
$this->addAction(GetGitHubCallback::getName(), new GetGitHubCallback());
$this->addAction(UpdateExternalDeployment::getName(), new UpdateExternalDeployment());
// Installations
$this->addAction(GetInstallation::getName(), new GetInstallation());
@@ -40,8 +37,5 @@ class Http extends Service
$this->addAction(ListRepositoryBranches::getName(), new ListRepositoryBranches());
$this->addAction(GetRepositoryContents::getName(), new GetRepositoryContents());
$this->addAction(CreateRepositoryDetections::getName(), new CreateRepositoryDetections());
// Events
$this->addAction(CreateGitHubEvent::getName(), new CreateGitHubEvent());
}
}
+64 -126
View File
@@ -486,9 +486,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$useAi = ($ai !== 'no');
$apiKey = $useAi ? System::getEnv('_APP_ASSISTANT_OPENAI_API_KEY', '') : '';
$aiChangelog = ''; // Track AI-generated changelog for PR description
Console::info('Checking for _APP_ASSISTANT_OPENAI_API_KEY... [' . (! empty($apiKey) ? 'FOUND' : 'NOT FOUND') . ']');
if (! empty($apiKey) && ! $examplesOnly) {
Console::info("Analyzing SDK changes with AI...");
Console::info("Using AI to determine version bump and changelog for {$language['name']} SDK...");
$aiResult = $this->generateVersionAndChangelog($language, $result);
if ($aiResult !== null) {
@@ -502,12 +502,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// Update the 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);
}
// Reload the language config with updated values
$language['version'] = $newVersion;
@@ -518,8 +512,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
} catch (\Throwable $exception) {
Console::error($exception->getMessage());
}
Console::success("AI determined version: {$newVersion} ({$aiResult['versionBump']} bump)");
} else {
Console::warning('AI analysis failed, using existing version');
Console::warning('AI version generation failed, using existing version');
}
}
@@ -528,45 +524,33 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$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 init && \
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 fetch origin && \
(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 pull origin ' . $repoBranch . ' && \
(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 fetch origin ' . $gitBranch . ' 2>/dev/null || git push -u origin ' . $gitBranch . ') && \
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 && \
(test -d .github && cp -r .github /tmp/.github-backup-$$ || true) && \
git rm -rf --cached . && \
git clean -fdx -e .git -e .github && \
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) && \
(test -d /tmp/.github-backup-$$ && cp -rn /tmp/.github-backup-$$/.github . && rm -rf /tmp/.github-backup-$$ || true) && \
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})");
}
git commit -m "' . $message . '" && \
git push -u origin ' . $gitBranch . '
');
Console::success("Pushed {$language['name']} SDK to {$gitUrl}");
if ($git) {
$prTitle = "feat: {$language['name']} SDK update for version {$language['version']}";
// 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}";
}
$prBody = "This PR contains updates to the {$language['name']} SDK for version {$language['version']} . ";
$repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
Console::info("Creating pull request for {$language['name']} SDK...");
@@ -781,47 +765,36 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
);
$prompt = <<<PROMPT
You are a technical writer generating a changelog for the {$language['name']} SDK release.
Analyze the git diff below and return a JSON response with the version bump type, new version number, and changelog.
## Versioning
Current version: {$language['version']}
Determine the semantic version bump:
- `major`: Breaking changes (removed/renamed public APIs, changed method signatures, dropped support)
- `minor`: New features that are backward-compatible (new methods, new optional parameters, new classes)
- `patch`: Bug fixes, documentation updates, refactors with no API surface change
When multiple change types are present, use the highest severity bump.
## Changelog guidelines
Write from the SDK consumer's perspective. Each entry should be a single line, max 15 words, in past tense.
Prefixes by category:
- **Breaking:** renamed/removed/changed APIs "Breaking: Renamed `oldMethod()` to `newMethod()`"
- **Added:** new features/options/endpoints "Added `streamResponse` option to client configuration"
- **Fixed:** bug fixes/corrections "Fixed incorrect timeout handling in retry logic"
- **Updated:** dependency bumps, doc improvements "Updated authentication examples for OAuth 2.0 flow"
Rules:
- Only include changes visible to SDK users (public API, behavior, docs, examples, CLI)
- Ignore: CI/CD pipelines (.github/), internal tooling, code formatting, test infrastructure
- Consolidate related changes into one entry (e.g., "Added `timeout`, `retries`, and `baseUrl` options" not three separate lines)
- If the diff contains zero user-facing changes, return a single entry: "No user-facing SDK changes"
- Do not speculate only document what the diff explicitly shows
## Diff context
- Stats: {{diff_stats}}
- Base repository: {{base}}
- Generated SDK path: {{target}}
```diff
{{diff}}
```
PROMPT;
Analyze the following git diff for the {$language['name']} SDK and determine:
Required output:
1. The appropriate version bump (`major`, `minor`, or `patch`) using semantic versioning.
2. The new version number (current version: {$language['version']}).
3. A clear, user-facing changelog.
Semantic versioning rules:
- `major`: breaking, non-backward-compatible changes.
- `minor`: backward-compatible new features.
- `patch`: backward-compatible fixes or small improvements.
Changelog rules:
- Include only user-facing SDK changes.
- Exclude internal/project-infra changes (for example `.github/workflows/**`, `.github/ISSUE_TEMPLATE/**`, CI/release automation/template cleanup).
- Never add "Internal housekeeping" style entries.
- If only excluded changes exist, return exactly: `* No user-facing SDK changes.`
Diff context:
- Stats: {{diff_stats}}
- Base repository: {{base}}
- Generated SDK path: {{target}}
Git diff (truncated to 500 lines):
```diff
{{diff}}
```
Provide your analysis in the requested JSON format.
PROMPT;
$options = (new DiffCheckOptions())
->setSchema($schema)
@@ -832,13 +805,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setExcludePaths([
'.github/workflows/**',
'.github/ISSUE_TEMPLATE/**',
'.git/**',
])
->setMaxDiffLines(500)
->setUserId('sdk-analyst');
Console::info("Running DiffCheck for {$language['name']} SDK...");
$result = (new DiffCheck())->run(
runner: $adapter,
base: DiffCheckRepository::remote($gitUrl, $repoBranch),
@@ -848,7 +819,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
);
if (!$result['hasChanges']) {
Console::info("No changes detected - SDK is up to date");
Console::warning("No changes detected for {$language['name']} SDK");
return null;
}
@@ -860,11 +831,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
return null;
}
Console::log('AI raw response:');
Console::log($responseContent);
Console::log('--- End of AI response ---');
$parsed = json_decode($responseContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Console::warning('Failed to parse AI response as JSON: ' . json_last_error_msg());
Console::log('Raw response:');
Console::log('Raw response that failed to parse:');
Console::log($responseContent);
return null;
@@ -875,14 +850,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
return null;
}
Console::success("✓ Analysis complete");
Console::log(" Version: {$language['version']}{$parsed['version']} ({$parsed['versionBump']} bump)");
Console::log(" Changelog:");
foreach (explode("\n", $parsed['changelog']) as $line) {
if (trim($line)) {
Console::log(" {$line}");
}
}
Console::info("AI analysis complete - Version bump: {$parsed['versionBump']}, New version: {$parsed['version']}");
return [
'version' => $parsed['version'],
@@ -896,16 +864,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
}
}
/**
* 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
*
@@ -916,7 +874,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
*/
private function updateSdkVersion(string $platform, string $sdkKey, string $newVersion): bool
{
$configPath = $this->getSdkConfigPath();
$configPath = __DIR__ . '/../../../../app/config/sdks.php';
if (! file_exists($configPath)) {
Console::error("Config file not found: {$configPath}");
@@ -926,13 +884,13 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$content = file_get_contents($configPath);
// First, try to find inline version in SDK array (pattern 1)
// Pattern matches: ['key' => 'nodejs', ... 'version' => '22.1.2']
$inlinePattern = '/(\[\s*[\'"]key[\'"]\s*=>\s*[\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*,[\s\S]*?[\'"]version[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"])/m';
// Find and replace the version for this specific SDK
// Pattern matches the version line in the SDK array
$pattern = '/(\[\s*[\'"]key[\'"]\s*=>\s*[\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*,[\s\S]*?[\'"]version[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"])/m';
if (preg_match($inlinePattern, $content, $matches)) {
if (preg_match($pattern, $content, $matches)) {
$oldVersion = $matches[2];
$newContent = preg_replace($inlinePattern, '${1}' . $newVersion . '${3}', $content);
$newContent = preg_replace($pattern, '${1}' . $newVersion . '${3}', $content);
if (file_put_contents($configPath, $newContent) !== false) {
Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config");
@@ -943,31 +901,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
return false;
}
} else {
Console::warning("Could not find version entry for {$sdkKey} in config");
return false;
}
// Second, try to find version in array format (pattern 2)
// Pattern matches: 'nodejs' => '22.1.2', or "nodejs" => "22.1.2",
// Also handles extra whitespace: 'nodejs' => '22.1.2',
$arrayPattern = '/([\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"],)/m';
if (preg_match($arrayPattern, $content, $matches)) {
$oldVersion = $matches[2];
$newContent = preg_replace($arrayPattern, '${1}' . $newVersion . '${3}', $content);
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;
}
}
Console::warning("Could not find version entry for {$sdkKey} in config");
return false;
}
/**
+47 -31
View File
@@ -3,15 +3,16 @@
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 Exception;
use Executor\Executor;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
@@ -47,7 +48,8 @@ class Functions extends Action
->inject('queueForFunctions')
->inject('queueForRealtime')
->inject('queueForEvents')
->inject('bus')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('log')
->inject('executor')
->inject('isResourceBlocked')
@@ -62,7 +64,8 @@ class Functions extends Action
Func $queueForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Bus $bus,
StatsUsage $queueForStatsUsage,
ExecutionEvent $queueForExecutions,
Log $log,
Executor $executor,
callable $isResourceBlocked
@@ -70,10 +73,7 @@ class Functions extends Action
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
throw new AppwriteException(
AppwriteException::GENERAL_ARGUMENT_INVALID,
'Functions worker: missing payload in schedule execution'
);
throw new Exception('Missing payload');
}
$type = $payload['type'] ?? '';
@@ -156,8 +156,9 @@ class Functions extends Action
queueForWebhooks: $queueForWebhooks,
queueForFunctions: $queueForFunctions,
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
bus: $bus,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -200,8 +201,9 @@ class Functions extends Action
queueForWebhooks: $queueForWebhooks,
queueForFunctions: $queueForFunctions,
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
bus: $bus,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -226,8 +228,9 @@ class Functions extends Action
queueForWebhooks: $queueForWebhooks,
queueForFunctions: $queueForFunctions,
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
bus: $bus,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -261,7 +264,7 @@ class Functions extends Action
private function fail(
string $message,
Document $project,
Bus $bus,
ExecutionEvent $queueForExecutions,
Document $function,
string $trigger,
string $path,
@@ -304,10 +307,10 @@ class Functions extends Action
'duration' => 0.0,
]);
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
));
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
}
/**
@@ -315,6 +318,7 @@ 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
@@ -337,8 +341,9 @@ class Functions extends Action
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
StatsUsage $queueForStatsUsage,
Event $queueForEvents,
Bus $bus,
ExecutionEvent $queueForExecutions,
Document $project,
Document $function,
Executor $executor,
@@ -366,19 +371,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, $bus, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $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, $bus, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $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, $bus, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event);
return;
}
@@ -387,10 +392,7 @@ class Functions extends Action
$runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []);
if (!\array_key_exists($function->getAttribute('runtime'), $runtimes)) {
throw new AppwriteException(
AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED,
\sprintf('Runtime "%s" is not supported', $function->getAttribute('runtime', '')),
);
throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported');
}
$runtime = $runtimes[$function->getAttribute('runtime')];
@@ -585,12 +587,26 @@ class Functions extends Action
$error = $th->getMessage();
$errorCode = $th->getCode();
} finally {
/** Persist final execution status and record usage */
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
spec: $spec,
));
/** 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()
;
}
$executionModel = new Execution();
@@ -624,7 +640,7 @@ class Functions extends Action
if (!empty($error)) {
throw new AppwriteException(
AppwriteException::GENERAL_SERVER_ERROR,
'Function execution failed: ' . ($error ?: 'No error message provided'),
$error ?: 'Function execution failed with no error message',
$errorCode
);
}
@@ -313,8 +313,6 @@ class Migrations extends Action
'files.write',
'functions.read',
'functions.write',
'sites.read',
'sites.write',
'tokens.read',
'tokens.write',
]
-32
View File
@@ -453,38 +453,6 @@ abstract class Format
break;
}
break;
case 'migrations':
switch ($method) {
case 'createAppwriteMigration':
case 'getAppwriteReport':
switch ($param) {
case 'resources':
return 'AppwriteMigrationResource';
}
break;
case 'createFirebaseMigration':
case 'getFirebaseReport':
switch ($param) {
case 'resources':
return 'FirebaseMigrationResource';
}
break;
case 'createSupabaseMigration':
case 'getSupabaseReport':
switch ($param) {
case 'resources':
return 'SupabaseMigrationResource';
}
break;
case 'createNHostMigration':
case 'getNHostReport':
switch ($param) {
case 'resources':
return 'NHostMigrationResource';
}
break;
}
break;
case 'project':
switch ($method) {
case 'getUsage':
@@ -53,12 +53,6 @@ class MigrationReport extends Model
'default' => 0,
'example' => 20,
])
->addRule(Resource::TYPE_SITE, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of sites to be migrated.',
'default' => 0,
'example' => 5,
])
->addRule('size', [
'type' => self::TYPE_INTEGER,
'description' => 'Size of files to be migrated in mb.',
@@ -47,18 +47,6 @@ class ProviderRepository extends Model
'default' => '',
'example' => 'main',
])
->addRule('providerInstallationId', [
'type' => self::TYPE_STRING,
'description' => 'VCS (Version Control System) installation ID.',
'default' => '',
'example' => '108104697',
])
->addRule('authorized', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Is VCS (Version Control System) repository authorized for the installation?',
'default' => false,
'example' => true,
])
->addRule('pushedAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Last commit date in ISO 8601 format.',
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace Utopia\Bus;
use Utopia\Span\Span;
class Bus
{
/** @var array<class-string<Event>, 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();
}
}
}
}
-7
View File
@@ -1,7 +0,0 @@
<?php
namespace Utopia\Bus;
interface Event
{
}
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace Utopia\Bus;
abstract class Listener
{
protected ?string $desc = null;
/** @var array<string> */
protected array $injections = [];
protected ?\Closure $callback = null;
abstract public static function getName(): string;
/**
* @return array<class-string<Event>>
*/
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<string> */
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;
}
}
@@ -3267,205 +3267,6 @@ trait DatabasesBase
], $this->getHeaders()));
}
public function testListDocumentsWithCache(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Filter to setup documents only, since other tests may have created additional docs in this collection.
$baseQueries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
];
// 1. Using cache with select queries, first request should miss cache.
$documents1 = $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' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents1['headers']['status-code']);
$this->assertEquals(3, $documents1['body']['total']);
$this->assertCount(3, $documents1['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(2017, $documents1['body'][$this->getRecordResource()][1]['releaseYear']);
$this->assertEquals(2019, $documents1['body'][$this->getRecordResource()][2]['releaseYear']);
$this->assertArrayHasKey('title', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents1['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Using cache with same select queries, should return cached results.
$documents2 = $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' => $baseQueries,
'ttl' => 30,
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
$this->assertEquals(3, $documents2['body']['total']);
$this->assertCount(3, $documents2['body'][$this->getRecordResource()]);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['releaseYear'], $documents2['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Using cache with same select queries but total is false, should return cached results just for documents.
$documents3 = $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' => $baseQueries,
'ttl' => 30,
'total' => false,
]);
$this->assertEquals(200, $documents3['headers']['status-code']);
$this->assertCount(3, $documents3['body'][$this->getRecordResource()]);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['$id'], $documents1['body'][$this->getRecordResource()][0]['$id']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['releaseYear'], $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertEquals(0, $documents3['body']['total']);
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('hit', $documents3['headers']['x-appwrite-cache']);
// 4. Using cache with different select queries, should miss cache.
$documents4 = $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::equal('$id', $docIds)->toString(),
Query::select(['title'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
'ttl' => 10,
]);
$this->assertEquals(200, $documents4['headers']['status-code']);
$this->assertEquals(3, $documents4['body']['total']);
$this->assertCount(3, $documents4['body'][$this->getRecordResource()]);
$this->assertEquals($documents4['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][1]['title'], $documents1['body'][$this->getRecordResource()][1]['title']);
$this->assertEquals($documents4['body'][$this->getRecordResource()][2]['title'], $documents1['body'][$this->getRecordResource()][2]['title']);
$this->assertArrayHasKey('x-appwrite-cache', $documents4['headers']);
$this->assertEquals('miss', $documents4['headers']['x-appwrite-cache']);
// 5. Not using cache at all
$documents5 = $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::equal('$id', $docIds)->toString(),
Query::select(['title', 'releaseYear', '$id'])->toString(),
Query::orderAsc('releaseYear')->toString(),
],
]);
$this->assertEquals(200, $documents5['headers']['status-code']);
$this->assertCount(3, $documents5['body'][$this->getRecordResource()]);
$this->assertEquals(1944, $documents5['body'][$this->getRecordResource()][0]['releaseYear']);
$this->assertArrayNotHasKey('x-appwrite-cache', $documents5['headers']);
sleep(10);
// 6. Using cache with same select queries but passed ttl time, should miss cache.
$documents6 = $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' => $baseQueries,
'ttl' => 10,
]);
$this->assertEquals(200, $documents6['headers']['status-code']);
$this->assertCount(3, $documents6['body'][$this->getRecordResource()]);
$this->assertArrayHasKey('title', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('releaseYear', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('$id', $documents6['body'][$this->getRecordResource()][0]);
$this->assertArrayHasKey('x-appwrite-cache', $documents6['headers']);
$this->assertEquals('miss', $documents6['headers']['x-appwrite-cache']);
}
public function testListDocumentsCacheBustedByAttributeChange(): void
{
$data = $this->setupDocuments();
$databaseId = $data['databaseId'];
$docIds = $data['documentIds'];
// Use different select queries from testListDocumentsWithCache to avoid cache key collision.
$queries = [
Query::equal('$id', $docIds)->toString(),
Query::select(['title', '$id'])->toString(),
Query::orderAsc('$createdAt')->toString(),
];
// 1. First request should miss cache.
$documents1 = $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' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents1['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
// 2. Same request should hit cache.
$documents2 = $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' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents2['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
// 3. Add a new attribute to the collection, which updates the collection's $updatedAt.
$attribute = $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']
]), [
'key' => 'cacheTestAttr',
'size' => 64,
'required' => false,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for the attribute to be ready
$this->waitForAttribute($databaseId, $data['moviesId'], 'cacheTestAttr');
// 4. Same request should now miss cache because collection $updatedAt changed.
$documents3 = $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' => $queries,
'ttl' => 300,
]);
$this->assertEquals(200, $documents3['headers']['status-code']);
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
$this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']);
}
public function testGetDocument(): void
{
$data = $this->getDocumentsList();
@@ -7,7 +7,6 @@ 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\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
@@ -1018,158 +1017,6 @@ trait MigrationsBase
]);
}
/**
* Sites
*/
public function testAppwriteMigrationSite(): void
{
$site = $this->client->call(Client::METHOD_POST, '/sites', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'siteId' => ID::unique(),
'name' => 'Test Site',
'framework' => 'other',
'buildRuntime' => 'node-22',
'adapter' => 'static',
'outputDirectory' => './',
]);
$this->assertEquals(201, $site['headers']['status-code'], 'Create site failed: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
$this->assertNotEmpty($site['body']['$id']);
$siteId = $site['body']['$id'];
// Create deployment
$deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'code' => $this->packageSite('static'),
'activate' => true,
]);
$this->assertEquals(202, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['$id']);
$deploymentId = $deployment['body']['$id'];
// Wait for deployment to be ready
$this->assertEventually(function () use ($siteId, $deploymentId) {
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [
'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('ready', $response['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($response['body'], JSON_PRETTY_PRINT));
}, 300000, 500);
// Create environment variable
$variable = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'key' => 'TEST_VAR',
'value' => 'test_value',
]);
$this->assertEquals(201, $variable['headers']['status-code']);
// Perform migration
$result = $this->performMigrationSync([
'resources' => [
Resource::TYPE_SITE,
Resource::TYPE_SITE_DEPLOYMENT,
Resource::TYPE_SITE_VARIABLE,
],
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
]);
$this->assertEquals('completed', $result['status']);
$this->assertEquals([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE], $result['resources']);
foreach ([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE] 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']);
}
// Verify site in destination
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [
'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($siteId, $response['body']['$id']);
$this->assertEquals('Test Site', $response['body']['name']);
$this->assertEquals('node-22', $response['body']['buildRuntime']);
$this->assertEquals('other', $response['body']['framework']);
$this->assertEquals('static', $response['body']['adapter']);
// Verify deployment in destination
$this->assertEventually(function () use ($siteId) {
$deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $deployments['headers']['status-code']);
$this->assertNotEmpty($deployments['body']);
$this->assertEquals(1, $deployments['body']['total']);
$this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT));
}, 100000, 500);
// Verify variable in destination
$variables = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/variables', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $variables['headers']['status-code']);
$this->assertEquals(1, $variables['body']['total']);
$this->assertEquals('TEST_VAR', $variables['body']['variables'][0]['key']);
// Cleanup
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
}
private function packageSite(string $site): CURLFile
{
$stdout = '';
$stderr = '';
$folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site";
$tarPath = "$folderPath/code.tar.gz";
Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr);
return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
}
/**
* Import documents from a CSV file.
*/
+4 -25
View File
@@ -11,8 +11,7 @@ trait RealtimeBase
array $channels = [],
array $headers = [],
?string $projectId = null,
?array $queries = null,
int $timeout = 2
?array $queries = null
): WebSocketClient {
if (is_null($projectId)) {
$projectId = $this->getProject()['$id'];
@@ -64,7 +63,7 @@ trait RealtimeBase
"ws://appwrite.test/v1/realtime?" . $queryString,
[
"headers" => $headers,
"timeout" => $timeout,
"timeout" => 45,
]
);
}
@@ -75,10 +74,9 @@ 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 = [], int $timeout = 2): WebSocketClient
private function getWebsocketWithCustomQuery(array $queryParams, array $headers = []): WebSocketClient
{
$queryString = http_build_query($queryParams);
@@ -86,7 +84,7 @@ trait RealtimeBase
"ws://appwrite.test/v1/realtime?" . $queryString,
[
"headers" => $headers,
"timeout" => $timeout,
"timeout" => 45,
]
);
}
@@ -133,23 +131,4 @@ trait RealtimeBase
$this->expectException(ConnectionException::class); // Check if server disconnected client
$client->close();
}
public function testConnectionRegionCheck(): void
{
/**
* Test for SUCCESS
* A project whose region matches the server region should connect successfully.
*/
$client = $this->getWebsocket(['documents']);
$response = json_decode($client->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('channels', $response['data']);
$this->assertContains('documents', $response['data']['channels']);
$client->close();
}
}
@@ -2535,35 +2535,29 @@ class RealtimeCustomClientQueryTest extends Scope
$projectId = 'console';
// Subscribe without queries - should receive all events
$clientNoQuery = $this->getWebsocket(
channels: ['tests'],
headers: ['origin' => 'http://localhost'],
projectId: $projectId,
timeout: 5
);
$clientNoQuery = $this->getWebsocket(['tests'], [
'origin' => 'http://localhost',
], $projectId);
$response = json_decode($clientNoQuery->receive(), true);
$this->assertEquals('connected', $response['type']);
// Subscribe with matching query - should receive events
$clientWithMatchingQuery = $this->getWebsocket(
channels: ['tests'],
headers: ['origin' => 'http://localhost'],
projectId: $projectId,
queries: [Query::equal('response', ['WS:/v1/realtime:passed'])->toString()],
timeout: 5
);
$clientWithMatchingQuery = $this->getWebsocket(['tests'], [
'origin' => 'http://localhost',
], $projectId, [
Query::equal('response', ['WS:/v1/realtime:passed'])->toString(),
]);
$response = json_decode($clientWithMatchingQuery->receive(), true);
$this->assertEquals('connected', $response['type']);
// Subscribe with non-matching query - should NOT receive events
$clientWithNonMatchingQuery = $this->getWebsocket(
channels: ['tests'],
headers: ['origin' => 'http://localhost'],
projectId: $projectId,
queries: [Query::equal('response', ['failed'])->toString()]
);
$clientWithNonMatchingQuery = $this->getWebsocket(['tests'], [
'origin' => 'http://localhost',
], $projectId, [
Query::equal('response', ['failed'])->toString(),
]);
$response = json_decode($clientWithNonMatchingQuery->receive(), true);
$this->assertEquals('connected', $response['type']);
@@ -123,8 +123,6 @@ class RealtimeCustomClientTest extends Scope
$this->assertNotEmpty($response['data']);
$this->assertNotEmpty($response['data']['user']);
$this->assertCount(16, $response['data']['channels']);
$this->assertIsList($response['data']['channels']);
$this->assertTrue(array_is_list($response['data']['channels']));
$this->assertContains('account', $response['data']['channels']);
$this->assertContains('account.' . $userId, $response['data']['channels']);
$this->assertContains('files', $response['data']['channels']);
@@ -820,7 +818,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
@@ -865,7 +863,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -921,7 +919,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -977,7 +975,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1009,7 +1007,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1058,7 +1056,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1086,7 +1084,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1114,7 +1112,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1151,7 +1149,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1180,7 +1178,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1209,7 +1207,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1256,7 +1254,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1435,7 +1433,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']);
@@ -1466,7 +1464,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']);
@@ -1516,7 +1514,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1570,7 +1568,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1623,7 +1621,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
@@ -1650,7 +1648,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
@@ -1689,7 +1687,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response1['type']);
$this->assertNotEmpty($response1['data']);
$this->assertArrayHasKey('timestamp', $response1['data']);
$this->assertCount(8, $response1['data']['channels']);
$this->assertCount(6, $response1['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']);
@@ -1720,7 +1718,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response2['type']);
$this->assertNotEmpty($response2['data']);
$this->assertArrayHasKey('timestamp', $response2['data']);
$this->assertCount(8, $response2['data']['channels']);
$this->assertCount(6, $response2['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']);
$this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']);
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']);
@@ -1773,7 +1771,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1811,7 +1809,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $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']);
@@ -1953,7 +1951,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -1992,7 +1990,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2042,7 +2040,7 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertArrayHasKey('timestamp', $response['data']);
$this->assertCount(8, $response['data']['channels']);
$this->assertCount(6, $response['data']['channels']);
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
@@ -2225,14 +2223,10 @@ class RealtimeCustomClientTest extends Scope
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
$client = $this->getWebsocket(
channels: ['executions'],
headers: [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session
],
timeout: 10
);
$client = $this->getWebsocket(['executions'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session
]);
$response = json_decode($client->receive(), true);
@@ -2571,66 +2565,17 @@ class RealtimeCustomClientTest extends Scope
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
/**
* Create a shared TablesDB database using the /tablesdb API.
* This database will then be accessed via both /databases and /tablesdb routes.
*/
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'TablesDB Cross API Realtime DB',
'name' => 'TablesDB Realtime DB',
]);
$databaseId = $database['body']['$id'];
$this->assertEquals(201, $database['headers']['status-code']);
/**
* Legacy collection in the shared database (/databases API).
*/
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'collectionId' => ID::unique(),
'name' => 'Legacy Actors',
'permissions' => [
Permission::create(Role::user($user['$id'])),
],
'documentSecurity' => true,
]);
$collectionId = $collection['body']['$id'];
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $collectionId) {
$attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals('available', $attribute['body']['status']);
}, 30000, 250);
/**
* TablesDB table in the same database (/tablesdb API).
*/
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
@@ -2671,92 +2616,20 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
/**
* Two different clients subscribing via legacy (documents/collections)
* and new (rows/tables) channels.
*/
$clientLegacy = $this->getWebsocket(['documents', 'collections'], [
$client = $this->getWebsocket(['documents', 'collections'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
]);
$response = json_decode($clientLegacy->receive(), true);
$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']);
$clientTables = $this->getWebsocket(['rows', 'tables'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session,
]);
$response = json_decode($clientTables->receive(), true);
$this->assertArrayHasKey('type', $response);
$this->assertArrayHasKey('data', $response);
$this->assertEquals('connected', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertContains('rows', $response['data']['channels']);
/**
* 1) Operation via legacy /databases API (document create).
* Both clients should receive an event that includes both document-
* style and row-style channels on the shared database.
*/
$documentId = ID::unique();
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'documentId' => $documentId,
'data' => [
'name' => 'Legacy Chris Evans',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $document['headers']['status-code']);
$legacyEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$legacyEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$legacyEventForLegacyClient, $legacyEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
// Legacy-style channels
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}", $channels);
// New rows-style channels mirrored for legacy API
$this->assertContains('rows', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
// TablesDB-prefixed channels should also be present for a tablesdb database
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
}
/**
* 2) Operation via /tablesdb API (row create).
* Both clients should again receive an event that now also includes
* the tablesdb-prefixed channels alongside the databases-prefixed ones.
*/
$rowId = ID::unique();
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
@@ -2777,296 +2650,31 @@ class RealtimeCustomClientTest extends Scope
$this->assertEquals(201, $row['headers']['status-code']);
$tablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$tablesEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$tablesEventForLegacyClient, $tablesEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
// Core tablesdb row channels
$this->assertContains('rows', $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
// Collections/legacy-style compatibility channels
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $channels);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $event['data']['events']);
$this->assertNotEmpty($event['data']['payload']);
$this->assertEquals('Chris Evans', $event['data']['payload']['name']);
}
/**
* 3) Legacy database accessed via /tablesdb routes.
* A database created via /databases but operated on via /tablesdb
* should also expose both legacy and tablesdb-prefixed channels.
*/
$legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'databaseId' => ID::unique(),
'name' => 'Legacy DB via TablesDB Route',
]);
$this->assertEquals(201, $legacyDatabase['headers']['status-code']);
$legacyDatabaseId = $legacyDatabase['body']['$id'];
$legacyTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'tableId' => ID::unique(),
'name' => 'Legacy Actors',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$legacyTableId = $legacyTable['body']['$id'];
$legacyColumn = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $legacyColumn['headers']['status-code']);
$this->assertEventually(function () use ($legacyDatabaseId, $legacyTableId) {
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()));
$this->assertEquals(200, $column['headers']['status-code']);
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
$legacyRowId = ID::unique();
$legacyRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'rowId' => $legacyRowId,
'data' => [
'name' => 'Legacy Tables Route',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $legacyRow['headers']['status-code']);
$legacyTablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
$legacyTablesEventForTablesClient = json_decode($clientTables->receive(), true);
foreach ([$legacyTablesEventForLegacyClient, $legacyTablesEventForTablesClient] as $event) {
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
$events = $event['data']['events'];
$this->assertIsList($channels);
$this->assertIsList($events);
// Core tablesdb row channels for legacy db accessed via tablesdb
$this->assertContains('rows', $channels);
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
// Legacy compatibility channels must also exist
$this->assertContains('documents', $channels);
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents", $channels);
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents.{$legacyRowId}", $channels);
}
$clientLegacy->close();
$clientTables->close();
}
public function testChannelTablesDBRowUpdate()
{
$user = $this->getUser();
$session = $user['session'] ?? '';
$projectId = $this->getProject()['$id'];
/**
* Create a tablesdb database + table + column + row.
*/
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Row Update DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'tableId' => ID::unique(),
'name' => 'Assembly',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$tableId = $table['body']['$id'];
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $column['headers']['status-code']);
$this->assertEventually(function () use ($databaseId, $tableId) {
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()));
$this->assertEquals(200, $column['headers']['status-code']);
$this->assertEquals('available', $column['body']['status']);
}, 120000, 500);
// Seed a row so we can listen to its update
$rowId = ID::unique();
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'rowId' => $rowId,
'data' => [
'name' => 'Initial Name',
],
'permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $row['headers']['status-code']);
/**
* Subscribe to a specific row channel using both legacy and tablesdb-style prefixes.
* This mimics a client subscribing to a concrete "resource" channel and
* expecting a single event list for updates.
*/
$client = $this->getWebsocket([
"databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
"tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
], [
'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->assertEquals('event', $response['type']);
$this->assertNotEmpty($response['data']);
$this->assertIsList($response['data']['channels']);
$this->assertArrayHasKey('timestamp', $response['data']);
/**
* Trigger a row update via the dedicated /tablesdb row update endpoint.
* Event label: databases.[databaseId].tables.[tableId].rows.[rowId].update
* Our Event + Realtime logic should enrich this to include:
* - databases.{dbId}.tables.{tableId}.rows.{rowId}.update
* - tablesdb.{dbId}.tables.{tableId}.rows.{rowId}.update
*/
$update = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getProject()['apiKey'],
], $this->getHeaders()), [
'data' => [
'name' => 'Updated Name',
],
]);
// Core channels for tablesdb row events
$this->assertContains('rows', $response['data']['channels']);
$this->assertEquals(200, $update['headers']['status-code']);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']);
$event = json_decode($client->receive(), true);
// Collections-style compatibility channels
$this->assertContains('documents', $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $response['data']['channels']);
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $response['data']['channels']);
$this->assertArrayHasKey('type', $event);
$this->assertArrayHasKey('data', $event);
$this->assertEquals('event', $event['type']);
$this->assertNotEmpty($event['data']);
$this->assertArrayHasKey('timestamp', $event['data']);
$channels = $event['data']['channels'];
$events = $event['data']['events'];
// Ensure channels and events are list-type arrays
$this->assertIsList($channels);
$this->assertIsList($events);
// Legacy + tablesdb row channels must be present
$this->assertContains('rows', $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
// Both databases.* and tablesdb.* update events should be emitted
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
$this->assertNotEmpty($event['data']['payload']);
$this->assertEquals('Updated Name', $event['data']['payload']['name']);
// Primary event should still be present
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $response['data']['events']);
$this->assertNotEmpty($response['data']['payload']);
$this->assertEquals('Chris Evans', $response['data']['payload']['name']);
$client->close();
}