mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.9.x' into chore/phpstan-level-4
This commit is contained in:
@@ -114,14 +114,24 @@ class Realtime extends MessagingAdapter
|
||||
}
|
||||
}
|
||||
|
||||
// Keep userId from onOpen/authentication when provided.
|
||||
// Fallback to existing stored value for subsequent subscribe upserts.
|
||||
$this->connections[$identifier] = [
|
||||
// Union channels/roles across all subscriptions on the connection; overwriting would
|
||||
// leave getSubscriptionMetadata and full unsubscribe operating on stale state.
|
||||
$existing = $this->connections[$identifier] ?? [];
|
||||
$existingChannels = $existing['channels'] ?? [];
|
||||
$existingRoles = $existing['roles'] ?? [];
|
||||
|
||||
$entry = [
|
||||
'projectId' => $projectId,
|
||||
'roles' => $roles,
|
||||
'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''),
|
||||
'channels' => $channels
|
||||
'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
|
||||
'userId' => $userId ?? ($existing['userId'] ?? ''),
|
||||
'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
|
||||
];
|
||||
|
||||
if (\array_key_exists('authorization', $existing)) {
|
||||
$entry['authorization'] = $existing['authorization'];
|
||||
}
|
||||
|
||||
$this->connections[$identifier] = $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +216,87 @@ class Realtime extends MessagingAdapter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single subscription from a connection, keeping the connection alive so
|
||||
* the client can resubscribe. Idempotent — returns true only when something was removed.
|
||||
*
|
||||
* @param mixed $connection
|
||||
* @param string $subscriptionId
|
||||
* @return bool
|
||||
*/
|
||||
public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool
|
||||
{
|
||||
$projectId = $this->connections[$connection]['projectId'] ?? '';
|
||||
if ($projectId === '' || !isset($this->subscriptions[$projectId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$removed = false;
|
||||
|
||||
foreach ($this->subscriptions[$projectId] as $role => $byChannel) {
|
||||
foreach ($byChannel as $channel => $byConnection) {
|
||||
if (!isset($byConnection[$connection][$subscriptionId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]);
|
||||
$removed = true;
|
||||
|
||||
if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) {
|
||||
unset($this->subscriptions[$projectId][$role][$channel][$connection]);
|
||||
}
|
||||
if (empty($this->subscriptions[$projectId][$role][$channel])) {
|
||||
unset($this->subscriptions[$projectId][$role][$channel]);
|
||||
}
|
||||
}
|
||||
if (empty($this->subscriptions[$projectId][$role])) {
|
||||
unset($this->subscriptions[$projectId][$role]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->subscriptions[$projectId])) {
|
||||
unset($this->subscriptions[$projectId]);
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
$this->recomputeConnectionState($connection);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the cached channels on the connection entry from the subscriptions tree.
|
||||
* Called after per-subscription removal so stale channel entries do not linger for later reads.
|
||||
*
|
||||
* Roles are deliberately NOT recomputed here. They represent the connection's authorization
|
||||
* context (set at onOpen, replaced on `authentication` / permission-change) and must survive
|
||||
* per-subscription removal — otherwise a client that unsubscribes every subscription and then
|
||||
* resubscribes would subscribe with an empty roles array and silently receive nothing.
|
||||
*
|
||||
* @param mixed $connection
|
||||
* @return void
|
||||
*/
|
||||
private function recomputeConnectionState(mixed $connection): void
|
||||
{
|
||||
if (!isset($this->connections[$connection])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectId = $this->connections[$connection]['projectId'] ?? '';
|
||||
$channels = [];
|
||||
|
||||
foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) {
|
||||
foreach ($byChannel as $channel => $byConnection) {
|
||||
if (isset($byConnection[$connection])) {
|
||||
$channels[$channel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->connections[$connection]['channels'] = \array_keys($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Channel has a subscriber.
|
||||
* @param string $projectId
|
||||
|
||||
@@ -49,11 +49,6 @@ class Create extends Action
|
||||
$databaseOverride = '';
|
||||
$dbScheme = '';
|
||||
$databaseSharedTables = [];
|
||||
$databaseSharedTablesV1 = [];
|
||||
$databaseSharedTablesV2 = [];
|
||||
$projectSharedTables = [];
|
||||
$projectSharedTablesV1 = [];
|
||||
$projectSharedTablesV2 = [];
|
||||
|
||||
switch ($databasetype) {
|
||||
case DOCUMENTSDB:
|
||||
@@ -62,7 +57,6 @@ class Create extends Action
|
||||
$databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE');
|
||||
$dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb');
|
||||
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')));
|
||||
$databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')));
|
||||
break;
|
||||
case VECTORSDB:
|
||||
$databases = Config::getParam('pools-vectorsdb', []);
|
||||
@@ -70,7 +64,6 @@ class Create extends Action
|
||||
$databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE');
|
||||
$dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql');
|
||||
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')));
|
||||
$databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')));
|
||||
break;
|
||||
default:
|
||||
// legacy/tablesdb
|
||||
@@ -78,8 +71,7 @@ class Create extends Action
|
||||
return $dsn;
|
||||
}
|
||||
|
||||
$isSharedTablesV1 = false;
|
||||
$isSharedTablesV2 = false;
|
||||
$isSharedTables = false;
|
||||
|
||||
if (!empty($dsn)) {
|
||||
try {
|
||||
@@ -90,10 +82,7 @@ class Create extends Action
|
||||
}
|
||||
|
||||
$projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
$projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1);
|
||||
$isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1);
|
||||
$isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2);
|
||||
$isSharedTables = \in_array($dsnHost, $projectSharedTables);
|
||||
}
|
||||
|
||||
if ($region !== 'default') {
|
||||
@@ -102,18 +91,14 @@ class Create extends Action
|
||||
return str_contains($value, $region);
|
||||
});
|
||||
}
|
||||
$databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1);
|
||||
|
||||
$index = \array_search($databaseOverride, $databases);
|
||||
if ($index !== false) {
|
||||
$selectedDsn = $databases[$index];
|
||||
} else {
|
||||
if (!empty($dsn) && !empty($databaseSharedTables)) {
|
||||
$beforeFilter = \array_values($databases);
|
||||
if ($isSharedTablesV1) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1));
|
||||
} elseif ($isSharedTablesV2) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2));
|
||||
if ($isSharedTables) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables));
|
||||
} else {
|
||||
$databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ use Utopia\Platform\Action;
|
||||
use Utopia\Queue\Message;
|
||||
use Utopia\Storage\Device;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Telemetry\Adapter as Telemetry;
|
||||
use Utopia\Telemetry\Counter;
|
||||
|
||||
use function Swoole\Coroutine\batch;
|
||||
|
||||
@@ -44,6 +46,7 @@ class Screenshots extends Action
|
||||
->inject('dbForProject')
|
||||
->inject('project')
|
||||
->inject('deviceForFiles')
|
||||
->inject('telemetry')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
@@ -53,7 +56,8 @@ class Screenshots extends Action
|
||||
Database $dbForPlatform,
|
||||
Database $dbForProject,
|
||||
Document $project,
|
||||
Device $deviceForFiles
|
||||
Device $deviceForFiles,
|
||||
Telemetry $telemetry
|
||||
): void {
|
||||
Console::log('Screenshot action started');
|
||||
|
||||
@@ -64,6 +68,7 @@ class Screenshots extends Action
|
||||
}
|
||||
|
||||
$screenshotMessage = Screenshot::fromArray($payload);
|
||||
$counter = $telemetry->createCounter('worker.screenshots.capture');
|
||||
|
||||
Console::log('Site screenshot started');
|
||||
|
||||
@@ -268,8 +273,24 @@ class Screenshots extends Action
|
||||
$date = \date('H:i:s');
|
||||
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[90m[$date] [90m[[0mappwrite[90m][33m Screenshot capturing failed. Deployment will continue. [0m\n");
|
||||
|
||||
$this->recordTelemetry($counter, 'failure');
|
||||
|
||||
throw $th;
|
||||
}
|
||||
|
||||
$this->recordTelemetry($counter, 'success');
|
||||
}
|
||||
|
||||
protected function recordTelemetry(Counter $counter, string $result): void
|
||||
{
|
||||
try {
|
||||
$counter->add(1, [
|
||||
'resourceType' => RESOURCE_TYPE_SITES,
|
||||
'result' => $result,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Telemetry should never affect screenshot processing.
|
||||
}
|
||||
}
|
||||
|
||||
protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs)
|
||||
|
||||
@@ -21,8 +21,6 @@ use Utopia\Database\DateTime;
|
||||
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\Validator\UID;
|
||||
use Utopia\DSN\DSN;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
@@ -209,32 +207,16 @@ class Create extends Action
|
||||
}
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
|
||||
$sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
|
||||
$sharedTablesV2 = !$projectTables && !$sharedTablesV1;
|
||||
$sharedTables = $sharedTablesV1 || $sharedTablesV2;
|
||||
|
||||
if (!$sharedTablesV2) {
|
||||
if ($projectTables) {
|
||||
$adapter = new DatabasePool($pools->get($dsn->getHost()));
|
||||
$dbForProject = new Database($adapter, $cache);
|
||||
$dbForProject->setDatabase(APP_DATABASE);
|
||||
|
||||
if ($sharedTables) {
|
||||
$tenant = null;
|
||||
if ($sharedTablesV1) {
|
||||
$tenant = $project->getSequence();
|
||||
}
|
||||
$dbForProject
|
||||
->setSharedTables(true)
|
||||
->setTenant($tenant)
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$dbForProject
|
||||
->setSharedTables(false)
|
||||
->setTenant(null)
|
||||
->setNamespace('_' . $project->getSequence());
|
||||
}
|
||||
$dbForProject
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setSharedTables(false)
|
||||
->setTenant(null)
|
||||
->setNamespace('_' . $project->getSequence());
|
||||
|
||||
$create = true;
|
||||
|
||||
@@ -244,27 +226,11 @@ class Create extends Action
|
||||
$create = false;
|
||||
}
|
||||
|
||||
if ($create || $projectTables) {
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$audit = new Audit($adapter);
|
||||
$audit->setup();
|
||||
}
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$audit = new Audit($adapter);
|
||||
$audit->setup();
|
||||
|
||||
if (!$create && $sharedTablesV1) {
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$attributes = $adapter->getAttributeDocuments();
|
||||
$indexes = $adapter->getIndexDocuments();
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom('audit'),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => 'audit',
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
}
|
||||
|
||||
if ($create || $sharedTablesV1) {
|
||||
if ($create) {
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', [])['projects'] ?? [];
|
||||
|
||||
@@ -279,37 +245,7 @@ class Create extends Action
|
||||
try {
|
||||
$dbForProject->createCollection($key, $attributes, $indexes);
|
||||
} catch (Duplicate) {
|
||||
try {
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom($key),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => $key,
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
} catch (Duplicate) {
|
||||
// Metadata already exists from concurrent creation
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// PostgreSQL adapter may throw a non-Duplicate exception when
|
||||
// a table or index already exists during concurrent project
|
||||
// creation in shared mode. Treat as duplicate if metadata
|
||||
// can be created successfully.
|
||||
try {
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom($key),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => $key,
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
} catch (Duplicate) {
|
||||
// Metadata already exists from concurrent creation
|
||||
} catch (\Throwable) {
|
||||
throw $e; // Rethrow original if metadata creation also fails
|
||||
}
|
||||
// Collection already exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Tasks;
|
||||
use Appwrite\SDK\Language\AgentSkills;
|
||||
use Appwrite\SDK\Language\Android;
|
||||
use Appwrite\SDK\Language\Apple;
|
||||
use Appwrite\SDK\Language\ClaudePlugin;
|
||||
use Appwrite\SDK\Language\CLI;
|
||||
use Appwrite\SDK\Language\CursorPlugin;
|
||||
use Appwrite\SDK\Language\Dart;
|
||||
@@ -451,6 +452,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
case 'cursor-plugin':
|
||||
$config = new CursorPlugin();
|
||||
break;
|
||||
case 'claude-plugin':
|
||||
$config = new ClaudePlugin();
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('Language "' . $language['key'] . '" not supported');
|
||||
}
|
||||
|
||||
@@ -652,11 +652,8 @@ class Deletes extends Action
|
||||
];
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
|
||||
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
|
||||
$sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
|
||||
$sharedTablesV2 = !$projectTables && !$sharedTablesV1;
|
||||
|
||||
$allDatabases = [
|
||||
new Document([
|
||||
@@ -759,23 +756,7 @@ class Deletes extends Action
|
||||
),
|
||||
$databasesToClean
|
||||
));
|
||||
} elseif ($sharedTablesV1) {
|
||||
/**
|
||||
* Temporary disabling deletes for internal collections
|
||||
*/
|
||||
$queries = \array_map(
|
||||
fn ($id) => Query::notEqual('$id', $id),
|
||||
$projectCollectionIds
|
||||
);
|
||||
|
||||
$queries[] = Query::orderAsc();
|
||||
|
||||
$this->deleteByGroup(
|
||||
Database::METADATA,
|
||||
$queries,
|
||||
$dbForProject
|
||||
);
|
||||
} elseif ($sharedTablesV2) {
|
||||
} else {
|
||||
$queries = \array_map(
|
||||
fn ($id) => Query::notEqual('$id', $id),
|
||||
$projectCollectionIds
|
||||
|
||||
@@ -194,9 +194,25 @@ class Migrations extends Action
|
||||
$migrationOptions = $migration->getAttribute('options');
|
||||
/** @var Database|null $projectDB */
|
||||
$projectDB = null;
|
||||
if ($credentials['projectId']) {
|
||||
$useAppwriteApiSource = false;
|
||||
if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) {
|
||||
throw new \Exception('Source projectId is required for Appwrite migrations');
|
||||
}
|
||||
|
||||
if (! empty($credentials['projectId'])) {
|
||||
$this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']);
|
||||
$projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
|
||||
if ($this->sourceProject->isEmpty()) {
|
||||
throw new \Exception('Source project not found for provided projectId');
|
||||
}
|
||||
|
||||
$sourceRegion = $this->sourceProject->getAttribute('region', 'default');
|
||||
$destinationRegion = $this->project->getAttribute('region', 'default');
|
||||
$useAppwriteApiSource = $source === SourceAppwrite::getName()
|
||||
&& $destination === DestinationAppwrite::getName()
|
||||
&& $sourceRegion !== $destinationRegion;
|
||||
if (! $useAppwriteApiSource) {
|
||||
$projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
|
||||
}
|
||||
}
|
||||
$getDatabasesDB = fn (Document $database): Database =>
|
||||
$this->getDatabasesDBForProject($database);
|
||||
@@ -232,7 +248,7 @@ class Migrations extends Action
|
||||
$credentials['endpoint'],
|
||||
$credentials['apiKey'],
|
||||
$getDatabasesDB,
|
||||
SourceAppwrite::SOURCE_DATABASE,
|
||||
$useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE,
|
||||
$projectDB,
|
||||
$queries
|
||||
),
|
||||
@@ -577,9 +593,10 @@ class Migrations extends Action
|
||||
|
||||
protected function getDatabasesDBForProject(Document $database)
|
||||
{
|
||||
if ($this->sourceProject) {
|
||||
if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) {
|
||||
return ($this->getDatabasesDB)($database, $this->sourceProject);
|
||||
}
|
||||
|
||||
return ($this->getDatabasesDB)($database);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user