2021-06-28 14:34:28 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Appwrite\Messaging\Adapter;
|
|
|
|
|
|
2025-05-14 06:14:07 +00:00
|
|
|
use Appwrite\Messaging\Adapter as MessagingAdapter;
|
|
|
|
|
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
|
2026-01-16 10:46:03 +00:00
|
|
|
use Appwrite\Utopia\Database\RuntimeQuery;
|
2024-03-06 17:34:21 +00:00
|
|
|
use Utopia\Database\DateTime;
|
|
|
|
|
use Utopia\Database\Document;
|
2025-12-22 12:31:00 +00:00
|
|
|
use Utopia\Database\Exception\Query as QueryException;
|
2022-12-14 15:42:25 +00:00
|
|
|
use Utopia\Database\Helpers\ID;
|
2022-12-14 16:04:06 +00:00
|
|
|
use Utopia\Database\Helpers\Role;
|
2025-12-22 12:31:00 +00:00
|
|
|
use Utopia\Database\Query;
|
2021-06-28 14:34:28 +00:00
|
|
|
|
2025-05-14 06:14:07 +00:00
|
|
|
class Realtime extends MessagingAdapter
|
2021-06-28 14:34:28 +00:00
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Connection Tree
|
2022-05-23 14:54:50 +00:00
|
|
|
*
|
2022-05-10 13:32:16 +00:00
|
|
|
* [CONNECTION_ID] ->
|
2021-06-28 14:34:28 +00:00
|
|
|
* 'projectId' -> [PROJECT_ID]
|
|
|
|
|
* 'roles' -> [ROLE_x, ROLE_Y]
|
|
|
|
|
* 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z]
|
|
|
|
|
*/
|
|
|
|
|
public array $connections = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Subscription Tree
|
2022-05-10 13:32:16 +00:00
|
|
|
*
|
|
|
|
|
* [PROJECT_ID] ->
|
|
|
|
|
* [ROLE_X] ->
|
2026-01-28 13:10:30 +00:00
|
|
|
* [CHANNEL_NAME_X] ->
|
2026-02-02 16:20:23 +00:00
|
|
|
* [CONNECTION_ID] ->
|
2026-02-04 06:27:57 +00:00
|
|
|
* [SUB_ID] -> ['strings' => [...], 'parsed' => [...]]
|
2026-02-02 14:15:21 +00:00
|
|
|
*
|
2026-02-04 06:27:57 +00:00
|
|
|
* Each subscription ID maps to query strings (for metadata) and pre-parsed Query objects (for filtering).
|
2026-02-02 16:20:23 +00:00
|
|
|
* Within a subscription: AND logic (all queries must match)
|
|
|
|
|
* Across subscriptions: OR logic (any subscription matching = send event)
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
|
|
|
|
public array $subscriptions = [];
|
|
|
|
|
|
2026-02-04 09:00:24 +00:00
|
|
|
private ?PubSubPool $pubSubPool = null;
|
2025-04-23 14:42:39 +00:00
|
|
|
|
2026-02-04 09:00:24 +00:00
|
|
|
/**
|
|
|
|
|
* Get the PubSubPool instance, initializing it lazily if needed.
|
|
|
|
|
* This allows unit tests to work without requiring the global $register.
|
|
|
|
|
*
|
|
|
|
|
* @return PubSubPool
|
|
|
|
|
*/
|
|
|
|
|
private function getPubSubPool(): PubSubPool
|
2025-04-23 14:42:39 +00:00
|
|
|
{
|
2026-02-04 09:00:24 +00:00
|
|
|
if ($this->pubSubPool === null) {
|
|
|
|
|
global $register;
|
|
|
|
|
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
|
|
|
|
|
}
|
|
|
|
|
return $this->pubSubPool;
|
2025-04-23 14:42:39 +00:00
|
|
|
}
|
|
|
|
|
|
2021-06-28 14:34:28 +00:00
|
|
|
/**
|
2026-02-02 16:20:23 +00:00
|
|
|
* Adds a subscription with a specific subscription ID.
|
2022-05-10 13:32:16 +00:00
|
|
|
*
|
|
|
|
|
* @param string $projectId
|
2026-02-02 14:15:21 +00:00
|
|
|
* @param mixed $identifier Connection ID
|
2026-02-02 16:20:23 +00:00
|
|
|
* @param string $subscriptionId Unique subscription ID
|
2026-02-02 14:15:21 +00:00
|
|
|
* @param array $roles User roles
|
2026-02-02 16:20:23 +00:00
|
|
|
* @param array $channels Channels to subscribe to (array of channel names)
|
|
|
|
|
* @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription)
|
2022-05-10 13:32:16 +00:00
|
|
|
* @return void
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
2026-02-02 16:20:23 +00:00
|
|
|
public function subscribe(string $projectId, mixed $identifier, string $subscriptionId, array $roles, array $channels, array $queryGroup = []): void
|
2021-06-28 14:34:28 +00:00
|
|
|
{
|
|
|
|
|
if (!isset($this->subscriptions[$projectId])) { // Init Project
|
|
|
|
|
$this->subscriptions[$projectId] = [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 06:27:57 +00:00
|
|
|
// Convert Query objects to strings and store both for this subscription
|
2026-02-02 14:15:21 +00:00
|
|
|
$queryStrings = [];
|
2026-02-04 06:27:57 +00:00
|
|
|
$parsedQueries = [];
|
2026-02-02 14:15:21 +00:00
|
|
|
if (empty($queryGroup)) {
|
|
|
|
|
// No queries means "listen to all events" - use select("*")
|
2026-02-04 06:27:57 +00:00
|
|
|
$selectAll = Query::select(['*']);
|
|
|
|
|
$queryStrings[] = $selectAll->toString();
|
|
|
|
|
$parsedQueries[] = $selectAll;
|
2026-01-28 13:10:30 +00:00
|
|
|
} else {
|
2026-02-02 14:15:21 +00:00
|
|
|
foreach ($queryGroup as $query) {
|
2026-01-28 13:10:30 +00:00
|
|
|
/** @var Query $query */
|
2026-02-02 14:15:21 +00:00
|
|
|
$queryStrings[] = $query->toString();
|
2026-02-04 06:27:57 +00:00
|
|
|
$parsedQueries[] = $query;
|
2026-01-28 13:10:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 06:27:57 +00:00
|
|
|
$subscriptionData = [
|
|
|
|
|
'strings' => $queryStrings,
|
|
|
|
|
'parsed' => $parsedQueries,
|
|
|
|
|
];
|
|
|
|
|
|
2021-06-28 14:34:28 +00:00
|
|
|
foreach ($roles as $role) {
|
2026-02-02 16:20:23 +00:00
|
|
|
if (!isset($this->subscriptions[$projectId][$role])) {
|
2021-06-28 14:34:28 +00:00
|
|
|
$this->subscriptions[$projectId][$role] = [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 16:20:23 +00:00
|
|
|
foreach ($channels as $channel) {
|
|
|
|
|
if (!isset($this->subscriptions[$projectId][$role][$channel])) {
|
|
|
|
|
$this->subscriptions[$projectId][$role][$channel] = [];
|
|
|
|
|
}
|
2026-01-28 13:10:30 +00:00
|
|
|
if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) {
|
|
|
|
|
$this->subscriptions[$projectId][$role][$channel][$identifier] = [];
|
|
|
|
|
}
|
2026-02-04 06:27:57 +00:00
|
|
|
$this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $subscriptionData;
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 16:20:23 +00:00
|
|
|
// Update connection info
|
2021-08-19 08:24:41 +00:00
|
|
|
$this->connections[$identifier] = [
|
2021-06-28 14:34:28 +00:00
|
|
|
'projectId' => $projectId,
|
|
|
|
|
'roles' => $roles,
|
2026-02-03 08:16:05 +00:00
|
|
|
'channels' => $channels
|
2021-06-28 14:34:28 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-03 06:13:23 +00:00
|
|
|
* Get subscription metadata for a connection.
|
|
|
|
|
* Retrieves subscription data including channels and queries directly from the subscriptions tree.
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $connection Connection ID
|
|
|
|
|
* @return array Array of [subscriptionId => ['channels' => string[], 'queries' => string[]]]
|
|
|
|
|
*/
|
2026-02-03 08:16:05 +00:00
|
|
|
public function getSubscriptionMetadata(mixed $connection): array
|
2026-02-03 06:13:23 +00:00
|
|
|
{
|
|
|
|
|
$projectId = $this->connections[$connection]['projectId'] ?? null;
|
|
|
|
|
$roles = $this->connections[$connection]['roles'] ?? [];
|
|
|
|
|
$channels = $this->connections[$connection]['channels'] ?? [];
|
|
|
|
|
|
|
|
|
|
if (!$projectId || empty($roles) || empty($channels)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$subscriptions = [];
|
|
|
|
|
|
|
|
|
|
// Extract subscription data from subscriptions tree
|
|
|
|
|
foreach ($roles as $role) {
|
|
|
|
|
if (!isset($this->subscriptions[$projectId][$role])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($channels as $channel) {
|
|
|
|
|
if (!isset($this->subscriptions[$projectId][$role][$channel][$connection])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 06:27:57 +00:00
|
|
|
foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subId => $subscriptionData) {
|
2026-02-03 06:13:23 +00:00
|
|
|
if (!isset($subscriptions[$subId])) {
|
|
|
|
|
$subscriptions[$subId] = [
|
|
|
|
|
'channels' => [],
|
2026-02-04 06:27:57 +00:00
|
|
|
'queries' => $subscriptionData['strings'] ?? []
|
2026-02-03 06:13:23 +00:00
|
|
|
];
|
|
|
|
|
}
|
2026-02-04 06:27:57 +00:00
|
|
|
if (!\in_array($channel, $subscriptions[$subId]['channels'])) {
|
2026-02-03 06:13:23 +00:00
|
|
|
$subscriptions[$subId]['channels'][] = $channel;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $subscriptions;
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-02 16:20:23 +00:00
|
|
|
* Removes all subscriptions for a connection.
|
2022-05-23 14:54:50 +00:00
|
|
|
*
|
2021-06-28 14:34:28 +00:00
|
|
|
* @param mixed $connection
|
2022-05-10 13:32:16 +00:00
|
|
|
* @return void
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
|
|
|
|
public function unsubscribe(mixed $connection): void
|
|
|
|
|
{
|
|
|
|
|
$projectId = $this->connections[$connection]['projectId'] ?? '';
|
|
|
|
|
$roles = $this->connections[$connection]['roles'] ?? [];
|
2026-01-28 13:10:30 +00:00
|
|
|
$channels = $this->connections[$connection]['channels'] ?? [];
|
2021-06-28 14:34:28 +00:00
|
|
|
|
|
|
|
|
foreach ($roles as $role) {
|
2026-02-02 16:20:23 +00:00
|
|
|
foreach ($channels as $channel) {
|
|
|
|
|
unset($this->subscriptions[$projectId][$role][$channel][$connection]); // dropping connection will drop all subscriptions
|
2021-06-28 14:34:28 +00:00
|
|
|
|
|
|
|
|
if (empty($this->subscriptions[$projectId][$role][$channel])) {
|
|
|
|
|
unset($this->subscriptions[$projectId][$role][$channel]); // Remove channel when no connections
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($this->subscriptions[$projectId][$role])) {
|
|
|
|
|
unset($this->subscriptions[$projectId][$role]); // Remove role when no channels
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($this->subscriptions[$projectId])) { // Remove project when no roles
|
|
|
|
|
unset($this->subscriptions[$projectId]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 06:13:23 +00:00
|
|
|
if (isset($this->connections[$connection])) {
|
|
|
|
|
unset($this->connections[$connection]);
|
|
|
|
|
}
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks if Channel has a subscriber.
|
2022-05-10 13:32:16 +00:00
|
|
|
* @param string $projectId
|
|
|
|
|
* @param string $role
|
|
|
|
|
* @param string $channel
|
2021-07-13 15:18:02 +00:00
|
|
|
* @return bool
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
|
|
|
|
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
|
|
|
|
|
{
|
2021-07-13 15:18:02 +00:00
|
|
|
//TODO: look into moving it to an abstract class in the parent class
|
2021-06-28 14:34:28 +00:00
|
|
|
if (empty($channel)) {
|
|
|
|
|
return array_key_exists($projectId, $this->subscriptions)
|
|
|
|
|
&& array_key_exists($role, $this->subscriptions[$projectId]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return array_key_exists($projectId, $this->subscriptions)
|
|
|
|
|
&& array_key_exists($role, $this->subscriptions[$projectId])
|
2026-01-28 13:10:30 +00:00
|
|
|
&& array_key_exists($channel, $this->subscriptions[$projectId][$role])
|
|
|
|
|
&& !empty($this->subscriptions[$projectId][$role][$channel]);
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2022-05-10 13:32:16 +00:00
|
|
|
* Sends an event to the Realtime Server
|
|
|
|
|
* @param string $projectId
|
|
|
|
|
* @param array $payload
|
2025-05-14 06:14:07 +00:00
|
|
|
* @param array $events
|
2022-05-10 13:32:16 +00:00
|
|
|
* @param array $channels
|
|
|
|
|
* @param array $roles
|
|
|
|
|
* @param array $options
|
|
|
|
|
* @return void
|
2025-05-14 06:14:07 +00:00
|
|
|
* @throws \Exception
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
2025-04-23 14:42:39 +00:00
|
|
|
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
|
2021-06-28 14:34:28 +00:00
|
|
|
{
|
2022-05-23 14:54:50 +00:00
|
|
|
if (empty($channels) || empty($roles) || empty($projectId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
|
|
|
|
|
$permissionsChanged = array_key_exists('permissionsChanged', $options) && $options['permissionsChanged'];
|
|
|
|
|
$userId = array_key_exists('userId', $options) ? $options['userId'] : null;
|
|
|
|
|
|
2026-02-04 09:00:24 +00:00
|
|
|
$this->getPubSubPool()->publish('realtime', json_encode([
|
2025-04-23 14:42:39 +00:00
|
|
|
'project' => $projectId,
|
|
|
|
|
'roles' => $roles,
|
|
|
|
|
'permissionsChanged' => $permissionsChanged,
|
|
|
|
|
'userId' => $userId,
|
|
|
|
|
'data' => [
|
|
|
|
|
'events' => $events,
|
|
|
|
|
'channels' => $channels,
|
|
|
|
|
'timestamp' => DateTime::formatTz(DateTime::now()),
|
|
|
|
|
'payload' => $payload
|
|
|
|
|
]
|
2025-05-14 06:14:07 +00:00
|
|
|
]));
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Identifies the receivers of all subscriptions, based on the permissions and event.
|
2022-05-10 13:32:16 +00:00
|
|
|
*
|
2021-06-28 14:34:28 +00:00
|
|
|
* Example of performance with an event with user:XXX permissions and with X users spread across 10 different channels:
|
2022-05-23 14:54:50 +00:00
|
|
|
* - 0.014 ms (±6.88%) | 10 Connections / 100 Subscriptions
|
|
|
|
|
* - 0.070 ms (±3.71%) | 100 Connections / 1,000 Subscriptions
|
2021-06-28 14:34:28 +00:00
|
|
|
* - 0.846 ms (±2.74%) | 1,000 Connections / 10,000 Subscriptions
|
|
|
|
|
* - 10.866 ms (±1.01%) | 10,000 Connections / 100,000 Subscriptions
|
|
|
|
|
* - 110.201 ms (±2.32%) | 100,000 Connections / 1,000,000 Subscriptions
|
2022-05-23 14:54:50 +00:00
|
|
|
* - 1,121.328 ms (±0.84%) | 1,000,000 Connections / 10,000,000 Subscriptions
|
2022-05-10 13:32:16 +00:00
|
|
|
*
|
2021-06-28 14:34:28 +00:00
|
|
|
* @param array $event
|
2026-02-02 14:15:21 +00:00
|
|
|
* @return array<int|string, array> Map of connection IDs to matched query groups
|
2021-06-28 14:34:28 +00:00
|
|
|
*/
|
2025-05-14 06:14:07 +00:00
|
|
|
public function getSubscribers(array $event): array
|
2021-06-28 14:34:28 +00:00
|
|
|
{
|
|
|
|
|
$receivers = [];
|
2021-08-19 08:34:32 +00:00
|
|
|
/**
|
|
|
|
|
* Check if project has subscriber.
|
|
|
|
|
*/
|
2021-06-28 14:34:28 +00:00
|
|
|
if (isset($this->subscriptions[$event['project']])) {
|
2021-08-19 08:34:32 +00:00
|
|
|
/**
|
|
|
|
|
* Iterate through each role.
|
|
|
|
|
*/
|
2021-06-28 14:34:28 +00:00
|
|
|
foreach ($this->subscriptions[$event['project']] as $role => $subscription) {
|
2021-08-19 08:34:32 +00:00
|
|
|
/**
|
|
|
|
|
* Iterate through each channel.
|
|
|
|
|
*/
|
2021-06-28 14:34:28 +00:00
|
|
|
foreach ($event['data']['channels'] as $channel) {
|
2021-08-19 08:34:32 +00:00
|
|
|
/**
|
|
|
|
|
* Check if channel has subscriber. Also taking care of the role in the event and the wildcard role.
|
|
|
|
|
*/
|
2021-06-28 14:34:28 +00:00
|
|
|
if (
|
|
|
|
|
\array_key_exists($channel, $this->subscriptions[$event['project']][$role])
|
2022-08-19 04:04:33 +00:00
|
|
|
&& (\in_array($role, $event['roles']) || \in_array(Role::any()->toString(), $event['roles']))
|
2021-06-28 14:34:28 +00:00
|
|
|
) {
|
2021-08-19 08:34:32 +00:00
|
|
|
/**
|
|
|
|
|
* Saving all connections that are allowed to receive this event.
|
|
|
|
|
*/
|
2026-01-28 13:10:30 +00:00
|
|
|
$payload = $event['data']['payload'] ?? [];
|
2026-02-02 16:20:23 +00:00
|
|
|
foreach ($this->subscriptions[$event['project']][$role][$channel] as $id => $subscriptions) {
|
2026-02-03 06:13:23 +00:00
|
|
|
$matchedSubscriptions = [];
|
2026-02-02 14:15:21 +00:00
|
|
|
|
2026-02-02 16:20:23 +00:00
|
|
|
// Process each subscription (OR logic across subscriptions)
|
2026-02-04 06:27:57 +00:00
|
|
|
foreach ($subscriptions as $subId => $subscriptionData) {
|
|
|
|
|
// Use pre-parsed queries instead of re-parsing on every event
|
|
|
|
|
$parsedQueries = $subscriptionData['parsed'] ?? [];
|
|
|
|
|
$queryStrings = $subscriptionData['strings'] ?? [];
|
|
|
|
|
|
2026-02-02 16:20:23 +00:00
|
|
|
// Check if this subscription matches (AND logic within subscription)
|
2026-02-03 08:59:34 +00:00
|
|
|
// Or if empty payload and select all as filter will return empty payload out of it even if it passed
|
2026-02-04 06:27:57 +00:00
|
|
|
$isEmptyPayloadAndSelectAll = !empty($parsedQueries) && RuntimeQuery::isSelectAll($parsedQueries[0]) && empty($payload);
|
2026-02-03 08:59:34 +00:00
|
|
|
if ($isEmptyPayloadAndSelectAll || !empty(RuntimeQuery::filter($parsedQueries, $payload))) {
|
2026-02-03 06:13:23 +00:00
|
|
|
$matchedSubscriptions[$subId] = $queryStrings;
|
2026-02-02 14:15:21 +00:00
|
|
|
}
|
2026-01-28 13:34:42 +00:00
|
|
|
}
|
2026-02-03 06:13:23 +00:00
|
|
|
|
|
|
|
|
// Only add connection to receivers if at least one subscription matched
|
|
|
|
|
if (!empty($matchedSubscriptions)) {
|
|
|
|
|
if (!isset($receivers[$id])) {
|
|
|
|
|
$receivers[$id] = [];
|
|
|
|
|
}
|
2026-02-04 06:27:57 +00:00
|
|
|
$receivers[$id] += $matchedSubscriptions;
|
2025-12-22 12:31:00 +00:00
|
|
|
}
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 13:10:30 +00:00
|
|
|
return $receivers;
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
|
|
|
|
|
/**
|
2022-05-10 13:32:16 +00:00
|
|
|
* Converts the channels from the Query Params into an array.
|
2021-06-30 11:36:58 +00:00
|
|
|
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
|
2022-05-10 13:32:16 +00:00
|
|
|
* @param array $channels
|
|
|
|
|
* @param string $userId
|
2022-05-23 14:54:50 +00:00
|
|
|
* @return array
|
2021-06-30 11:36:58 +00:00
|
|
|
*/
|
2021-07-13 15:18:02 +00:00
|
|
|
public static function convertChannels(array $channels, string $userId): array
|
2021-06-30 11:36:58 +00:00
|
|
|
{
|
|
|
|
|
$channels = array_flip($channels);
|
|
|
|
|
|
|
|
|
|
foreach ($channels as $key => $value) {
|
|
|
|
|
switch (true) {
|
2025-05-07 08:41:28 +00:00
|
|
|
case str_starts_with($key, 'account.'):
|
2021-06-30 11:36:58 +00:00
|
|
|
unset($channels[$key]);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case $key === 'account':
|
2021-07-13 15:18:02 +00:00
|
|
|
if (!empty($userId)) {
|
|
|
|
|
$channels['account.' . $userId] = $value;
|
2021-06-30 11:36:58 +00:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $channels;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 16:20:23 +00:00
|
|
|
/**
|
|
|
|
|
* Constructs subscriptions from query parameters.
|
2026-02-03 06:13:23 +00:00
|
|
|
*
|
2026-02-02 16:20:23 +00:00
|
|
|
* Reconstructs subscription structure from query params where subscription indices can span multiple channels.
|
|
|
|
|
* Format: {channel}[subscriptionIndex][]=query1&{channel}[subscriptionIndex][]=query2
|
2026-02-03 06:13:23 +00:00
|
|
|
*
|
2026-02-02 16:20:23 +00:00
|
|
|
* Example:
|
|
|
|
|
* - tests[0][]=select(*) → subscription 0: channels=["tests"]
|
|
|
|
|
* - tests[1][]=equal(...) & prod[1][]=equal(...) → subscription 1: channels=["tests", "prod"]
|
2026-02-03 06:13:23 +00:00
|
|
|
*
|
2026-02-02 16:20:23 +00:00
|
|
|
* @param array $channelNames Array of channel names
|
|
|
|
|
* @param callable $getQueryParam Callable that takes a channel name and returns its query param value (null if not present)
|
|
|
|
|
* @return array Array indexed by subscription index: [index => ['channels' => string[], 'queries' => Query[]]]
|
|
|
|
|
* @throws QueryException
|
|
|
|
|
*/
|
|
|
|
|
public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array
|
|
|
|
|
{
|
|
|
|
|
$subscriptionsByIndex = [];
|
|
|
|
|
|
|
|
|
|
foreach ($channelNames as $channel) {
|
|
|
|
|
$channelSubscriptions = $getQueryParam($channel);
|
|
|
|
|
|
|
|
|
|
// Backward compatibility: if no channel-specific query params, treat as subscription 0 with select("*")
|
|
|
|
|
if ($channelSubscriptions === null) {
|
|
|
|
|
if (!isset($subscriptionsByIndex[0])) {
|
|
|
|
|
$subscriptionsByIndex[0] = [
|
|
|
|
|
'channels' => [],
|
|
|
|
|
'queries' => []
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
$subscriptionsByIndex[0]['channels'][] = $channel;
|
|
|
|
|
if (empty($subscriptionsByIndex[0]['queries'])) {
|
|
|
|
|
$subscriptionsByIndex[0]['queries'] = [Query::select(['*'])];
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!is_array($channelSubscriptions)) {
|
|
|
|
|
$channelSubscriptions = [$channelSubscriptions];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($channelSubscriptions as $subscriptionIndex => $subscription) {
|
|
|
|
|
if (!isset($subscriptionsByIndex[$subscriptionIndex])) {
|
|
|
|
|
$subscriptionsByIndex[$subscriptionIndex] = [
|
|
|
|
|
'channels' => [],
|
|
|
|
|
'queries' => []
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!in_array($channel, $subscriptionsByIndex[$subscriptionIndex]['channels'])) {
|
|
|
|
|
$subscriptionsByIndex[$subscriptionIndex]['channels'][] = $channel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($subscriptionsByIndex[$subscriptionIndex]['queries'])) {
|
|
|
|
|
$queriesToParse = is_array($subscription) ? $subscription : [$subscription];
|
|
|
|
|
$parsedQueries = self::convertQueries($queriesToParse);
|
|
|
|
|
$subscriptionsByIndex[$subscriptionIndex]['queries'] = $parsedQueries;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $subscriptionsByIndex;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 12:31:00 +00:00
|
|
|
/**
|
|
|
|
|
* Converts the queries from the Query Params into an array.
|
2026-02-02 14:15:21 +00:00
|
|
|
* @param array|string $queries
|
2025-12-22 12:31:00 +00:00
|
|
|
* @return array
|
2026-02-02 14:15:21 +00:00
|
|
|
* @throws QueryException
|
2025-12-22 12:31:00 +00:00
|
|
|
*/
|
2026-02-02 14:15:21 +00:00
|
|
|
public static function convertQueries(mixed $queries): array
|
2025-12-22 12:31:00 +00:00
|
|
|
{
|
|
|
|
|
$queries = Query::parseQueries($queries);
|
2026-01-16 12:48:24 +00:00
|
|
|
$stack = $queries;
|
|
|
|
|
$allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES);
|
|
|
|
|
while (!empty($stack)) {
|
2026-02-02 14:15:21 +00:00
|
|
|
/** @var Query $query */
|
2026-01-16 12:48:24 +00:00
|
|
|
$query = array_pop($stack);
|
|
|
|
|
$method = $query->getMethod();
|
|
|
|
|
if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
|
|
|
|
|
$unsupportedMethod = $method;
|
2025-12-24 15:32:03 +00:00
|
|
|
throw new QueryException(
|
|
|
|
|
"Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}"
|
|
|
|
|
);
|
2025-12-22 12:31:00 +00:00
|
|
|
}
|
2026-02-02 14:15:21 +00:00
|
|
|
|
|
|
|
|
// Validate select queries - only select("*") is allowed
|
|
|
|
|
if ($method === Query::TYPE_SELECT) {
|
|
|
|
|
RuntimeQuery::validateSelectQuery($query);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 12:48:24 +00:00
|
|
|
if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR], true)) {
|
2026-02-04 06:27:18 +00:00
|
|
|
\array_push($stack, ...$query->getValues());
|
2026-01-16 12:48:24 +00:00
|
|
|
}
|
2025-12-22 12:31:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $queries;
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-30 11:36:58 +00:00
|
|
|
/**
|
2021-12-13 09:29:50 +00:00
|
|
|
* Create channels array based on the event name and payload.
|
2021-12-06 12:03:12 +00:00
|
|
|
*
|
2022-05-10 13:32:16 +00:00
|
|
|
* @param string $event
|
|
|
|
|
* @param Document $payload
|
|
|
|
|
* @param Document|null $project
|
2024-09-09 08:52:37 +00:00
|
|
|
* @param Document|null $database
|
2025-05-09 09:17:01 +00:00
|
|
|
* @param Document|null $collection
|
2024-09-09 08:52:37 +00:00
|
|
|
* @param Document|null $bucket
|
2022-05-23 14:54:50 +00:00
|
|
|
* @return array
|
2024-09-09 08:52:37 +00:00
|
|
|
* @throws \Exception
|
2021-06-30 11:36:58 +00:00
|
|
|
*/
|
2026-02-03 04:19:37 +00:00
|
|
|
public static function fromPayload(string $event, Document $payload, ?Document $project = null, ?Document $database = null, ?Document $collection = null, ?Document $bucket = null): array
|
2021-06-30 11:36:58 +00:00
|
|
|
{
|
|
|
|
|
$channels = [];
|
2021-07-13 15:18:02 +00:00
|
|
|
$roles = [];
|
2021-06-30 11:36:58 +00:00
|
|
|
$permissionsChanged = false;
|
2021-12-06 12:03:12 +00:00
|
|
|
$projectId = null;
|
2022-05-10 13:25:49 +00:00
|
|
|
// TODO: add method here to remove all the magic index accesses
|
2022-04-13 12:39:31 +00:00
|
|
|
$parts = explode('.', $event);
|
2021-06-30 11:36:58 +00:00
|
|
|
|
2022-04-13 12:39:31 +00:00
|
|
|
switch ($parts[0]) {
|
|
|
|
|
case 'users':
|
2021-07-13 10:07:28 +00:00
|
|
|
$channels[] = 'account';
|
2022-04-13 12:39:31 +00:00
|
|
|
$channels[] = 'account.' . $parts[1];
|
2022-08-19 04:04:33 +00:00
|
|
|
$roles = [Role::user(ID::custom($parts[1]))->toString()];
|
2021-06-30 11:36:58 +00:00
|
|
|
break;
|
2023-03-10 12:20:24 +00:00
|
|
|
case 'rules':
|
2025-04-27 05:03:44 +00:00
|
|
|
case 'migrations':
|
2023-03-14 11:13:03 +00:00
|
|
|
$channels[] = 'console';
|
2024-09-30 14:32:50 +00:00
|
|
|
$channels[] = 'projects.' . $project->getId();
|
2023-03-14 11:13:03 +00:00
|
|
|
$projectId = 'console';
|
2023-03-10 12:20:24 +00:00
|
|
|
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
|
|
|
|
|
break;
|
2024-10-07 14:58:34 +00:00
|
|
|
case 'projects':
|
|
|
|
|
$channels[] = 'console';
|
|
|
|
|
$channels[] = 'projects.' . $parts[1];
|
|
|
|
|
$projectId = 'console';
|
|
|
|
|
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
|
|
|
|
|
break;
|
2022-04-13 12:39:31 +00:00
|
|
|
case 'teams':
|
|
|
|
|
if ($parts[2] === 'memberships') {
|
|
|
|
|
$permissionsChanged = $parts[4] ?? false;
|
|
|
|
|
$channels[] = 'memberships';
|
|
|
|
|
$channels[] = 'memberships.' . $parts[3];
|
|
|
|
|
} else {
|
|
|
|
|
$permissionsChanged = $parts[2] === 'create';
|
|
|
|
|
$channels[] = 'teams';
|
|
|
|
|
$channels[] = 'teams.' . $parts[1];
|
|
|
|
|
}
|
2022-08-19 04:04:33 +00:00
|
|
|
$roles = [Role::team(ID::custom($parts[1]))->toString()];
|
2021-06-30 11:36:58 +00:00
|
|
|
break;
|
2022-06-22 10:51:49 +00:00
|
|
|
case 'databases':
|
2025-05-07 08:41:28 +00:00
|
|
|
$resource = $parts[4] ?? '';
|
|
|
|
|
if (in_array($resource, ['columns', 'attributes', 'indexes'])) {
|
2022-04-13 12:39:31 +00:00
|
|
|
$channels[] = 'console';
|
2024-09-30 14:32:50 +00:00
|
|
|
$channels[] = 'projects.' . $project->getId();
|
2022-04-13 12:39:31 +00:00
|
|
|
$projectId = 'console';
|
2022-08-19 04:04:33 +00:00
|
|
|
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
|
2025-05-07 08:41:28 +00:00
|
|
|
} elseif (in_array($resource, ['rows', 'documents'])) {
|
2022-06-22 10:51:49 +00:00
|
|
|
if ($database->isEmpty()) {
|
2025-05-07 05:03:54 +00:00
|
|
|
throw new \Exception('Database needs to be passed to Realtime for Document/Row events in the Database.');
|
2022-06-22 10:51:49 +00:00
|
|
|
}
|
2025-05-09 09:17:01 +00:00
|
|
|
if ($collection->isEmpty()) {
|
2025-05-07 05:03:54 +00:00
|
|
|
throw new \Exception('Collection or the Table needs to be passed to Realtime for Document/Row events in the Database.');
|
2022-04-13 12:39:31 +00:00
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
|
2025-10-06 14:43:36 +00:00
|
|
|
$tableId = $payload->getAttribute('$tableId', '');
|
|
|
|
|
$collectionId = $payload->getAttribute('$collectionId', '');
|
|
|
|
|
$resourceId = $tableId ?: $collectionId;
|
|
|
|
|
|
2025-04-27 05:03:44 +00:00
|
|
|
$channels[] = 'rows';
|
2025-10-06 14:43:36 +00:00
|
|
|
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows';
|
|
|
|
|
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId();
|
2022-08-19 04:04:33 +00:00
|
|
|
|
2025-05-06 07:22:05 +00:00
|
|
|
$channels[] = 'documents';
|
2025-10-06 14:43:36 +00:00
|
|
|
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents';
|
|
|
|
|
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId();
|
2025-05-06 07:22:05 +00:00
|
|
|
|
2025-05-09 09:17:01 +00:00
|
|
|
$roles = $collection->getAttribute('documentSecurity', false)
|
|
|
|
|
? \array_merge($collection->getRead(), $payload->getRead())
|
|
|
|
|
: $collection->getRead();
|
2021-12-16 18:12:06 +00:00
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
break;
|
2022-04-13 12:39:31 +00:00
|
|
|
case 'buckets':
|
|
|
|
|
if ($parts[2] === 'files') {
|
2022-04-18 16:21:45 +00:00
|
|
|
if ($bucket->isEmpty()) {
|
2022-08-03 04:17:49 +00:00
|
|
|
throw new \Exception('Bucket needs to be passed to Realtime for File events in the Storage.');
|
2022-04-13 12:39:31 +00:00
|
|
|
}
|
|
|
|
|
$channels[] = 'files';
|
|
|
|
|
$channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files';
|
|
|
|
|
$channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.' . $payload->getId();
|
2022-08-19 04:04:33 +00:00
|
|
|
|
2022-08-14 05:24:50 +00:00
|
|
|
$roles = $bucket->getAttribute('fileSecurity', false)
|
2022-08-13 14:55:15 +00:00
|
|
|
? \array_merge($bucket->getRead(), $payload->getRead())
|
|
|
|
|
: $bucket->getRead();
|
2022-02-13 07:02:34 +00:00
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
|
|
|
|
|
break;
|
2022-04-13 12:39:31 +00:00
|
|
|
case 'functions':
|
|
|
|
|
if ($parts[2] === 'executions') {
|
|
|
|
|
if (!empty($payload->getRead())) {
|
|
|
|
|
$channels[] = 'console';
|
2024-09-30 14:32:50 +00:00
|
|
|
$channels[] = 'projects.' . $project->getId();
|
2022-04-13 12:39:31 +00:00
|
|
|
$channels[] = 'executions';
|
|
|
|
|
$channels[] = 'executions.' . $payload->getId();
|
|
|
|
|
$channels[] = 'functions.' . $payload->getAttribute('functionId');
|
|
|
|
|
$roles = $payload->getRead();
|
|
|
|
|
}
|
|
|
|
|
} elseif ($parts[2] === 'deployments') {
|
2022-02-28 12:24:35 +00:00
|
|
|
$channels[] = 'console';
|
2024-09-30 14:32:50 +00:00
|
|
|
$channels[] = 'projects.' . $project->getId();
|
2022-10-25 19:16:05 +00:00
|
|
|
$projectId = 'console';
|
2022-08-19 04:04:33 +00:00
|
|
|
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
|
2021-06-30 11:36:58 +00:00
|
|
|
}
|
2022-04-13 12:39:31 +00:00
|
|
|
|
2024-10-28 11:00:06 +00:00
|
|
|
break;
|
|
|
|
|
case 'sites':
|
|
|
|
|
if ($parts[2] === 'deployments') {
|
|
|
|
|
$channels[] = 'console';
|
|
|
|
|
$channels[] = 'projects.' . $project->getId();
|
|
|
|
|
$projectId = 'console';
|
|
|
|
|
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
|
|
|
|
|
}
|
2021-06-30 11:36:58 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'channels' => $channels,
|
2021-07-13 15:18:02 +00:00
|
|
|
'roles' => $roles,
|
2021-12-06 12:03:12 +00:00
|
|
|
'permissionsChanged' => $permissionsChanged,
|
|
|
|
|
'projectId' => $projectId
|
2021-06-30 11:36:58 +00:00
|
|
|
];
|
|
|
|
|
}
|
2021-06-28 14:34:28 +00:00
|
|
|
}
|