diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 4daf9865b3..4cf77311ab 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -6,9 +6,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; } -use Swoole\Constant; use Utopia\App; -use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\MockServer\Utopia\Exception; use Utopia\MockServer\Utopia\File; @@ -24,10 +22,11 @@ use Utopia\Validator\Host; use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; -use Swoole\Process; -use Swoole\Http\Server; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; +use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; +use Utopia\WebSocket\Adapter\Swoole; +use Utopia\WebSocket\Server as WebSocketServer; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -39,23 +38,24 @@ const APP_PLATFORM_CONSOLE = 'console'; const APP_STORAGE_CACHE = '/storage/cache'; -$http = new Server( - host: '0.0.0.0', - port: App::getEnv('PORT', 80), - mode: SWOOLE_PROCESS -); $payloadSize = 6 * (1024 * 1024); // 6MB $workerNumber = swoole_cpu_num() * intval(App::getEnv('_APP_WORKER_PER_CORE', 6)); -$http->set([ - 'worker_num' => $workerNumber, +$adapter = new Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); +$adapter + ->setPackageMaxLength($payloadSize) + ->setWorkerNumber($workerNumber); + +// Settings the adapter doesn't expose directly. +$adapter->getNative()->set([ 'open_http2_protocol' => true, 'http_compression' => true, 'http_compression_level' => 6, - 'package_max_length' => $payloadSize, 'buffer_output_size' => $payloadSize, ]); +$server = new WebSocketServer($adapter); + // Version Route for CLI App::get('/v1/health/version') ->desc('Get version') @@ -862,21 +862,23 @@ function ($utopia, $error, $request, $response) { ['utopia', 'error', 'request', 'response'] ); -$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize) { +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$server->error(function (\Throwable $error, string $action) { + Console::error("[ws:{$action}] " . $error->getMessage()); +}); + +$server->onStart(function () use ($payloadSize) { Console::success('Server started successfully (max payload is ' . number_format($payloadSize) . ' bytes)'); - Console::info("Master pid {$http->master_pid}, manager pid {$http->manager_pid}"); - - // Listen for ctrl + c - Process::signal( - 2, - function () use ($http) { - Console::log('Stop by Ctrl+C'); - $http->shutdown(); - } - ); }); -$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { +$server->onRequest(function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); $response = new UtopiaSwooleResponse($swooleResponse); @@ -885,4 +887,29 @@ function () use ($http) { $app->run($request, $response); }); -$http->start(); +$server->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($server, $realtimeProtocol) { + $path = (string) ($swooleRequest->server['request_uri'] ?? ''); + if ($path !== '/v1/realtime') { + // Reject upgrades on any other path with a policy-violation close. + $server->close($fd, 1008); + return; + } + + $project = (string) ($swooleRequest->get['project'] ?? ''); + if ($project === '') { + $server->close($fd, 1008); + return; + } + + $realtimeProtocol->open($server, $fd, $project); +}); + +$server->onMessage(function (int $fd, string $data) use ($server, $realtimeProtocol) { + $realtimeProtocol->message($server, $fd, $data); +}); + +$server->onClose(function (int $fd) use ($realtimeProtocol) { + $realtimeProtocol->close($fd); +}); + +$server->start(); diff --git a/mock-server/composer.json b/mock-server/composer.json index c9252629ca..59841b2f30 100644 --- a/mock-server/composer.json +++ b/mock-server/composer.json @@ -10,7 +10,9 @@ "utopia-php/framework": "0.33.*", "utopia-php/database": "0.48.*", "utopia-php/cli": "0.16.*", - "utopia-php/swoole": "0.8.*" + "utopia-php/swoole": "0.8.*", + "utopia-php/websocket": "^1.0", + "utopia-php/query": "^0.3.1" }, "require-dev": { "swoole/ide-helper": "5.1.2" diff --git a/mock-server/composer.lock b/mock-server/composer.lock index d6957f70c3..34ee2ad208 100644 --- a/mock-server/composer.lock +++ b/mock-server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8e3df78a113bec48bb61da0227ea50f", + "content-hash": "735665b96dcec1d67a5ea33973a240fb", "packages": [ { "name": "brick/math", @@ -2389,6 +2389,55 @@ }, "time": "2023-09-01T17:25:28+00:00" }, + { + "name": "utopia-php/query", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/query.git", + "reference": "5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/query/zipball/5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10", + "reference": "5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "brianium/paratest": "*", + "laravel/pint": "*", + "mongodb/mongodb": "^2.0", + "phpstan/phpstan": "*", + "phpunit/phpcov": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Query\\": "src/Query" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library providing a query abstraction for filtering, ordering, and pagination", + "keywords": [ + "framework", + "php", + "query", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/query/issues", + "source": "https://github.com/utopia-php/query/tree/0.3.1" + }, + "time": "2026-05-11T06:35:45+00:00" + }, { "name": "utopia-php/swoole", "version": "0.8.4", @@ -2534,6 +2583,55 @@ "source": "https://github.com/utopia-php/validators/tree/0.1.0" }, "time": "2025-11-18T11:05:46+00:00" + }, + { + "name": "utopia-php/websocket", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/websocket.git", + "reference": "d230de8d4d2450184297327238ed1fbde676b8d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/websocket/zipball/d230de8d4d2450184297327238ed1fbde676b8d2", + "reference": "d230de8d4d2450184297327238ed1fbde676b8d2", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.15", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^9.5.5", + "swoole/ide-helper": "5.1.2", + "textalk/websocket": "1.5.2", + "workerman/workerman": "4.1.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\WebSocket\\": "src/WebSocket" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple abstraction for WebSocket servers.", + "keywords": [ + "framework", + "php", + "upf", + "utopia", + "websocket" + ], + "support": { + "issues": "https://github.com/utopia-php/websocket/issues", + "source": "https://github.com/utopia-php/websocket/tree/1.0.0" + }, + "time": "2026-02-05T13:40:16+00:00" } ], "packages-dev": [ @@ -2577,5 +2675,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/mock-server/src/Utopia/Realtime/Connection.php b/mock-server/src/Utopia/Realtime/Connection.php new file mode 100644 index 0000000000..9fc258bfd0 --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Connection.php @@ -0,0 +1,59 @@ + string[], 'queries' => string[]]`. + * + * @var array + */ + public array $subscriptions = []; + + public function __construct(int $fd, string $project = '') + { + $this->fd = $fd; + $this->project = $project; + } + + public function subscribe(string $subscriptionId, array $channels, array $queries): void + { + $this->subscriptions[$subscriptionId] = [ + 'channels' => array_values($channels), + 'queries' => array_values($queries), + ]; + } + + public function unsubscribe(string $subscriptionId): void + { + unset($this->subscriptions[$subscriptionId]); + } + + /** + * Return subscription IDs whose channel set intersects the given channels. + * + * @param string[] $channels + * @return string[] + */ + public function matchingSubscriptions(array $channels): array + { + $matches = []; + foreach ($this->subscriptions as $id => $sub) { + if (!empty(array_intersect($channels, $sub['channels']))) { + $matches[] = $id; + } + } + return $matches; + } +} diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php new file mode 100644 index 0000000000..005333d014 --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -0,0 +1,268 @@ + server: { type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence' | 'ping', data: ... } + * server -> client: { type: 'connected' | 'event' | 'response' | 'pong' | 'error', data: ... } + * + * The mock does NOT enforce real authorization or query filtering; it + * acknowledges client requests with shaped responses so SDK code paths + * (subscribe/unsubscribe/presence) can be exercised end-to-end. + */ +class Protocol +{ + /** Connection state by Swoole fd. */ + private array $connections = []; + + public function open(Server $server, int $fd, string $project): void + { + $this->connections[$fd] = new Connection($fd, $project); + + $this->send($server, $fd, 'connected', [ + 'channels' => [], + 'user' => null, + ]); + } + + public function close(int $fd): void + { + unset($this->connections[$fd]); + } + + public function message(Server $server, int $fd, string $raw): void + { + $connection = $this->connections[$fd] ?? null; + if ($connection === null) { + return; + } + + $message = json_decode($raw, true); + if (!is_array($message) || !isset($message['type'])) { + $this->error($server, $fd, 'Invalid message', 400); + return; + } + + $type = (string) $message['type']; + $data = $message['data'] ?? null; + + try { + match ($type) { + 'authentication' => $this->handleAuth($connection, $data), + 'subscribe' => $this->handleSubscribe($server, $connection, $data), + 'unsubscribe' => $this->handleUnsubscribe($connection, $data), + 'presence' => $this->handlePresence($server, $connection, $data), + 'ping' => $this->send($server, $fd, 'pong'), + default => $this->error($server, $fd, "Unknown message type: {$type}", 400), + }; + } catch (Exception $e) { + $this->error($server, $fd, $e->getMessage(), $e->getCode() ?: 400); + } catch (\Throwable $e) { + $this->error($server, $fd, $e->getMessage(), 500); + } + } + + private function handleAuth(Connection $connection, mixed $data): void + { + // Mock accepts any session and synthesises a user object. + if (is_array($data) && !empty($data['session'])) { + $connection->user = [ + '$id' => 'user_' . substr(md5((string) $data['session']), 0, 8), + 'name' => 'Mock User', + ]; + } + } + + private function handleSubscribe(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'subscribe payload must be an array', 400); + } + + // SDKs send a list of subscription rows: [{ subscriptionId, channels, queries }] + $rows = $this->isList($data) ? $data : [$data]; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $subscriptionId = isset($row['subscriptionId']) ? (string) $row['subscriptionId'] : ''; + $channels = isset($row['channels']) && is_array($row['channels']) ? $row['channels'] : []; + $queries = isset($row['queries']) && is_array($row['queries']) ? $row['queries'] : []; + + if ($subscriptionId === '' || empty($channels)) { + continue; + } + $connection->subscribe($subscriptionId, $channels, $queries); + + $eventPayload = ['response' => 'WS:/v1/realtime:passed']; + if (!$this->subscriptionMatchesPayload($queries, $eventPayload)) { + continue; + } + + $this->send($server, $connection->fd, 'event', [ + 'channels' => array_values($channels), + 'events' => ['test.event'], + 'timestamp' => gmdate('Y-m-d\TH:i:s.000\+00:00'), + 'payload' => $eventPayload, + 'subscriptions' => [$subscriptionId], + ]); + } + } + + /** + * @param string[] $queries + * @param array $payload + */ + private function subscriptionMatchesPayload(array $queries, array $payload): bool + { + if (empty($queries)) { + return true; + } + + try { + $parsed = Query::parseQueries(array_values(array_map('strval', $queries))); + } catch (QueryException) { + return false; + } + + foreach ($parsed as $query) { + if (!$this->evaluateQuery($query, $payload)) { + return false; + } + } + return true; + } + + /** + * Evaluate a parsed Query against the event payload + * + * @param array $payload + */ + private function evaluateQuery(Query $query, array $payload): bool + { + $method = $query->getMethod(); + $values = $query->getValues(); + + if ($method === Method::Select) { + // select('*') is the realtime convention for "listen to all". + return count($values) === 1 && $values[0] === '*'; + } + + if ($method === Method::And) { + foreach ($values as $sub) { + if (!$sub instanceof Query || !$this->evaluateQuery($sub, $payload)) { + return false; + } + } + return true; + } + + if ($method === Method::Or) { + foreach ($values as $sub) { + if ($sub instanceof Query && $this->evaluateQuery($sub, $payload)) { + return true; + } + } + return false; + } + + $attribute = $query->getAttribute(); + if ($attribute === '' || !array_key_exists($attribute, $payload)) { + return false; + } + + $actual = $payload[$attribute]; + + return match ($method) { + Method::Equal => in_array($actual, $values, true), + Method::NotEqual => !in_array($actual, $values, true), + Method::LessThan => isset($values[0]) && $actual < $values[0], + Method::LessThanEqual => isset($values[0]) && $actual <= $values[0], + Method::GreaterThan => isset($values[0]) && $actual > $values[0], + Method::GreaterThanEqual => isset($values[0]) && $actual >= $values[0], + Method::IsNull => $actual === null, + Method::IsNotNull => $actual !== null, + default => false, // unimplemented matcher → fail closed + }; + } + + private function handleUnsubscribe(Connection $connection, mixed $data): void + { + if (!is_array($data)) { + return; + } + $rows = $this->isList($data) ? $data : [$data]; + foreach ($rows as $row) { + if (is_array($row) && isset($row['subscriptionId'])) { + $connection->unsubscribe((string) $row['subscriptionId']); + } + } + } + + private function handlePresence(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data) || !isset($data['status'])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'presence payload requires status', 400); + } + + $now = gmdate('Y-m-d\TH:i:s.000\+00:00'); + $presence = [ + '$id' => $data['presenceId'] ?? ('presence_' . bin2hex(random_bytes(6))), + '$sequence' => '1', + '$createdAt' => $now, + '$updatedAt' => $now, + '$permissions' => $data['permissions'] ?? [], + 'userInternalId' => '1', + 'userId' => $connection->user['$id'] ?? '674af8f3e12a5f9ac0be', + 'status' => (string) $data['status'], + 'source' => 'WS', + ]; + if (isset($data['metadata']) && is_array($data['metadata'])) { + $presence['metadata'] = $data['metadata']; + } + if (isset($data['expiresAt'])) { + $presence['expiry'] = (string) $data['expiresAt']; + } + + $this->send($server, $connection->fd, 'response', [ + 'to' => 'presence', + 'presence' => $presence, + ]); + } + + private function send(Server $server, int $fd, string $type, mixed $data = null): void + { + $payload = ['type' => $type]; + if ($data !== null) { + $payload['data'] = $data; + } + $server->send([$fd], json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + + private function error(Server $server, int $fd, string $message, int $code = 400): void + { + $this->send($server, $fd, 'error', [ + 'message' => $message, + 'code' => $code, + ]); + } + + private function isList(array $value): bool + { + if ($value === []) { + return false; + } + return array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/src/Spec/Swagger2.php b/src/Spec/Swagger2.php index dcb4c120bc..b32752cdbd 100644 --- a/src/Spec/Swagger2.php +++ b/src/Spec/Swagger2.php @@ -598,7 +598,8 @@ public function getDefinitions(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { @@ -660,7 +661,8 @@ public function getRequestModels(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { diff --git a/templates/android/library/src/main/java/io/package/Channel.kt.twig b/templates/android/library/src/main/java/io/package/Channel.kt.twig index a187b8ac01..f92fdacc7e 100644 --- a/templates/android/library/src/main/java/io/package/Channel.kt.twig +++ b/templates/android/library/src/main/java/io/package/Channel.kt.twig @@ -14,6 +14,7 @@ public interface _Func public interface _Execution public interface _Team public interface _Membership +public interface _Presence public interface _Resolved // Union type for actionable channels @@ -81,6 +82,9 @@ class Channel private constructor( fun membership(id: String): Channel<_Membership> = Channel(listOf("memberships", normalize(id))) + fun presence(id: String): Channel<_Presence> = + Channel(listOf("presences", normalize(id))) + fun account(): String = "account" // Global events @@ -90,6 +94,7 @@ class Channel private constructor( fun executions(): String = "executions" fun teams(): String = "teams" fun memberships(): String = "memberships" + fun presences(): String = "presences" } } @@ -251,3 +256,31 @@ fun Channel<_Membership>.update(): Channel<_Resolved> = @JvmName("deleteMembership") fun Channel<_Membership>.delete(): Channel<_Resolved> = this.resolve("delete") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("createPresence") +fun Channel<_Presence>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("upsertPresence") +fun Channel<_Presence>.upsert(): Channel<_Resolved> = + this.resolve("upsert") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("updatePresence") +fun Channel<_Presence>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("deletePresence") +fun Channel<_Presence>.delete(): Channel<_Resolved> = + this.resolve("delete") diff --git a/templates/android/library/src/main/java/io/package/models/Model.kt.twig b/templates/android/library/src/main/java/io/package/models/Model.kt.twig index 84056b4ea8..3260b6be84 100644 --- a/templates/android/library/src/main/java/io/package/models/Model.kt.twig +++ b/templates/android/library/src/main/java/io/package/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 04fbda122e..0d551a4e5e 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -34,12 +34,13 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private const val TYPE_ERROR = "error" private const val TYPE_EVENT = "event" private const val TYPE_PONG = "pong" - private const val TYPE_RESPONSE = "response" private const val HEARTBEAT_INTERVAL = 20_000L // 20 seconds private var socket: WebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() + private var pendingPresence: Map? = null + @Volatile private var appConnected = false private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -47,6 +48,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() + private val presenceLock = Any() } private fun createSocket() { @@ -126,6 +128,10 @@ class Realtime(client: Client) : Service(client), CoroutineScope { reconnect = false closeSocket() } + synchronized(presenceLock) { + pendingPresence = null + } + appConnected = false } private fun sendPendingSubscribes() { @@ -175,6 +181,43 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } } + /** + * Fire-and-forget presence upsert. Records the latest payload in state so + * that — if the WebSocket isn't open yet, or later reconnects — the most + * recent presence is automatically (re)sent on the next `connected` event. + * When the socket is already open, the frame is sent immediately. + * + * @param status Presence status (required). + * @param presenceId Presence ID (required). + * @param permissions Optional permission list to attach to the presence document. + * @param metadata Optional metadata payload. + */ + fun upsertPresence( + status: String, + presenceId: String, + permissions: List? = null, + metadata: Map? = null, + ) { + val data = mutableMapOf( + "status" to status, + "presenceId" to presenceId, + ) + permissions?.let { data["permissions"] = it } + metadata?.let { data["metadata"] = it } + + synchronized(presenceLock) { + pendingPresence = data + } + flushPendingPresence() + } + + private fun flushPendingPresence() { + if (!appConnected) return + val data = synchronized(presenceLock) { pendingPresence } ?: return + val ws = socket ?: return + ws.send(mapOf("type" to "presence", "data" to data).toJson()) + } + fun subscribe( vararg channels: Channel<*>, callback: (RealtimeResponseEvent) -> Unit, @@ -324,7 +367,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { when (message.type) { TYPE_ERROR -> handleResponseError(message) TYPE_CONNECTED -> handleResponseConnected(message) - TYPE_RESPONSE -> handleResponseAction(message) TYPE_EVENT -> handleResponseEvent(message) TYPE_PONG -> {} } @@ -337,17 +379,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { synchronized(subscriptionLock) { activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } } + appConnected = true sendPendingSubscribes() - } - - private fun handleResponseAction(message: RealtimeResponse) { - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + flushPendingPresence() } private fun handleResponseError(message: RealtimeResponse) { - throw message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + val ex = message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + throw ex } private suspend fun handleResponseEvent(message: RealtimeResponse) { @@ -378,6 +417,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { super.onClosing(webSocket, code, reason) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() if (!reconnect || code == RealtimeCode.POLICY_VIOLATION.value) { reconnect = true @@ -402,6 +442,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { super.onFailure(webSocket, t, response) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() t.printStackTrace() } diff --git a/templates/apple/Sources/Channel.swift.twig b/templates/apple/Sources/Channel.swift.twig index b45b979b83..1672409e39 100644 --- a/templates/apple/Sources/Channel.swift.twig +++ b/templates/apple/Sources/Channel.swift.twig @@ -14,6 +14,7 @@ public struct _Func {} public struct _Execution {} public struct _Team {} public struct _Membership {} +public struct _Presence {} public struct _Resolved {} public enum ChannelValidationError: Swift.Error { @@ -89,11 +90,15 @@ public enum Channel { public static func membership(_ id: String) throws -> RealtimeChannel<_Membership> { return RealtimeChannel<_Membership>(["memberships", try normalize(id)]) } - + + public static func presence(_ id: String) throws -> RealtimeChannel<_Presence> { + return RealtimeChannel<_Presence>(["presences", try normalize(id)]) + } + public static func account() -> String { return "account" } - + // Global events public static func documents() -> String { "documents" } public static func rows() -> String { "rows" } @@ -101,6 +106,7 @@ public enum Channel { public static func executions() -> String { "executions" } public static func teams() -> String { "teams" } public static func memberships() -> String { "memberships" } + public static func presences() -> String { "presences" } } // MARK: - DATABASE ROUTE @@ -221,11 +227,30 @@ extension RealtimeChannel where T == _Membership { public func create() -> RealtimeChannel<_Resolved> { return self.resolve("create") } - + public func update() -> RealtimeChannel<_Resolved> { return self.resolve("update") } - + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Presence> +extension RealtimeChannel where T == _Presence { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func upsert() -> RealtimeChannel<_Resolved> { + return self.resolve("upsert") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + public func delete() -> RealtimeChannel<_Resolved> { return self.resolve("delete") } diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index e0bef860e9..355fdb8404 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -14,16 +14,18 @@ open class Realtime : Service { private let TYPE_ERROR = "error" private let TYPE_EVENT = "event" private let TYPE_PONG = "pong" - private let TYPE_RESPONSE = "response" private let DEBOUNCE_NANOS = 1_000_000 private let HEARTBEAT_INTERVAL: UInt64 = 20_000_000_000 // 20 seconds in nanoseconds private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() + private var pendingPresence: [String: Any]? = nil + private var appConnected = false private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") + let presenceSync = DispatchQueue(label: "PresenceSync") private var subCallDepth = 0 private var reconnectAttempts = 0 @@ -136,6 +138,10 @@ open class Realtime : Service { public func disconnect() async throws { activeSubscriptions.removeAll() pendingSubscribes.removeAll() + presenceSync.sync { + pendingPresence = nil + } + appConnected = false reconnect = false try await closeSocket() } @@ -196,6 +202,58 @@ open class Realtime : Service { return channel.toString() } + /// Fire-and-forget presence upsert. Records the latest payload in state so + /// that — if the WebSocket isn't open yet, or later reconnects — the most + /// recent presence is automatically (re)sent on the next `connected` event. + /// When the socket is already open, the frame is sent immediately. + /// + /// - Parameters: + /// - status: The presence status (required). + /// - presenceId: The presence ID (required). + /// - permissions: Optional permission list to attach to the presence document. + /// - metadata: Optional metadata payload. + public func upsertPresence( + status: String, + presenceId: String, + permissions: [String]? = nil, + metadata: [String: Any]? = nil + ) throws { + var data: [String: Any] = [ + "status": status, + "presenceId": presenceId, + ] + if let permissions = permissions { + data["permissions"] = permissions + } + if let metadata = metadata { + data["metadata"] = metadata + } + + presenceSync.sync { + pendingPresence = data + } + try flushPendingPresence() + } + + private func flushPendingPresence() throws { + var data: [String: Any]? + presenceSync.sync { + data = pendingPresence + } + guard let payloadData = data, let ws = socketClient, ws.isConnected, appConnected else { + return + } + let payload: [String: Any] = [ + "type": "presence", + "data": payloadData + ] + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: jsonData, encoding: .utf8) else { + throw {{ spec.title | caseUcfirst }}Error(message: "Failed to encode presence payload") + } + ws.send(text: text) + } + public func subscribe( channel: ChannelValue, callback: @escaping (RealtimeResponseEvent) -> Void, @@ -356,13 +414,9 @@ extension Realtime: WebSocketClientDelegate { for subscriptionId in activeSubscriptions.keys { enqueuePendingSubscribe(subscriptionId: subscriptionId) } + appConnected = true sendPendingSubscribes() - } - - private func handleResponseAction(from json: [String: Any]) { - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + try? flushPendingPresence() } public func onMessage(text: String) { @@ -381,8 +435,6 @@ extension Realtime: WebSocketClientDelegate { } case "connected": handleResponseConnected(from: json) - case TYPE_RESPONSE: - handleResponseAction(from: json) case TYPE_EVENT: handleResponseEvent(from: json) case TYPE_PONG: @@ -393,10 +445,11 @@ extension Realtime: WebSocketClientDelegate { } public func onClose(channel: NIO.Channel, data: Data) async throws { + appConnected = false stopHeartbeat() onCloseCallbacks.forEach { $0() } - + if (!reconnect) { reconnect = true return @@ -421,7 +474,9 @@ extension Realtime: WebSocketClientDelegate { } func handleResponseError(from json: [String: Any]) throws { - throw {{ spec.title | caseUcfirst }}Error(message: json["message"] as? String ?? "Unknown error") + let message = json["message"] as? String ?? "Unknown error" + let error = {{ spec.title | caseUcfirst }}Error(message: message) + throw error } func handleResponseEvent(from json: [String: Any]) { diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index 6ee3db0310..5f6861da4c 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -9,7 +9,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% endfor %} {%~ if definition.additionalProperties %} - final Map data; + final Map {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}; {% endif %} {{ definition.name | caseUcfirst | overrideIdentifier}}({% if definition.properties | length or definition.additionalProperties %}{{ '{' }}{% endif %} @@ -18,7 +18,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% if property.required %}required {% endif %}this.{{ property.name | escapeKeyword }}, {% endfor %} {% if definition.additionalProperties %} - required this.data, + required this.{{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} {% if definition.properties | length or definition.additionalProperties %}{{ '}' }}{% endif %}); @@ -54,7 +54,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {%- endif -%}, {% endfor %} {% if definition.additionalProperties %} - data: map["data"] ?? map, + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"] ?? map, {% endif %} ); } @@ -66,13 +66,13 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model "{{ property.name | escapeDollarSign }}": {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword}}.map((p) => p.toMap()).toList(){% else %}{{property.name | escapeKeyword}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword }}{% endif %}, {% endfor %} {% if definition.additionalProperties %} - "data": data, + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}": {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} }; } {% if definition.additionalProperties %} - T convertTo(T Function(Map) fromJson) => fromJson(data); + T convertTo(T Function(Map) fromJson) => fromJson({{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}); {% endif %} {% for property in definition.properties %} {% if property.sub_schema %} diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index 10d64deda5..b65485b288 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -17,7 +17,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - public Dictionary Data { get; private set; } + public Dictionary {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} { get; private set; } {%~ endif %} public {{ definition.name | caseUcfirst | overrideIdentifier }}( @@ -26,7 +26,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - Dictionary data + Dictionary {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }} {%~ endif %} ) { @@ -34,7 +34,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property_name(definition, property) | overrideProperty(definition.name) }} = {{ property.name | caseCamel | escapeKeyword }}; {%~ endfor %} {%~ if definition.additionalProperties %} - Data = data; + {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }}; {%~ endif %} } @@ -81,8 +81,8 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} {%- if definition.additionalProperties %} - data: map.TryGetValue("data", out var dataValue) - ? (Dictionary)dataValue + {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }}: map.TryGetValue("{{ definition.additionalPropertiesKey | default('data') }}", out var additionalPropsValue) + ? (Dictionary)additionalPropsValue : map {%- endif ~%} ); @@ -94,13 +94,13 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - { "data", Data } + { "{{ definition.additionalPropertiesKey | default('data') }}", {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} } {%~ endif %} }; {%~ if definition.additionalProperties %} public T ConvertTo(Func, T> fromJson) => - fromJson.Invoke(Data); + fromJson.Invoke({{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }}); {%~ endif %} {%~ for property in definition.properties %} {%~ if property.sub_schema %} diff --git a/templates/flutter/lib/channel.dart.twig b/templates/flutter/lib/channel.dart.twig index 64712c193e..da20db1209 100644 --- a/templates/flutter/lib/channel.dart.twig +++ b/templates/flutter/lib/channel.dart.twig @@ -14,6 +14,7 @@ class _Func {} class _Execution {} class _Team {} class _Membership {} +class _Presence {} class _Resolved {} // Helper function for normalizing ID @@ -72,6 +73,9 @@ class Channel { static Channel<_Membership> membership(String id) => Channel<_Membership>._(['memberships', _normalize(id)]); + static Channel<_Presence> presence(String id) => + Channel<_Presence>._(['presences', _normalize(id)]); + static String account() => 'account'; // Global events @@ -81,6 +85,7 @@ class Channel { static String executions() => 'executions'; static String teams() => 'teams'; static String memberships() => 'memberships'; + static String presences() => 'presences'; } // --- DATABASE ROUTE --- @@ -156,3 +161,11 @@ extension MembershipChannel on Channel<_Membership> { Channel<_Resolved> update() => _resolve('update'); Channel<_Resolved> delete() => _resolve('delete'); } + +/// Only available on Channel<_Presence> +extension PresenceChannel on Channel<_Presence> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> upsert() => _resolve('upsert'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} diff --git a/templates/flutter/lib/src/client_io.dart.twig b/templates/flutter/lib/src/client_io.dart.twig index 14ea8bfd62..9fcdf1234d 100644 --- a/templates/flutter/lib/src/client_io.dart.twig +++ b/templates/flutter/lib/src/client_io.dart.twig @@ -184,7 +184,6 @@ class ClientIO extends ClientBase with ClientMixin { '${packageInfo.packageName}/${packageInfo.version} $device', ); } catch (e) { - debugPrint('Error getting device info: $e'); device = Platform.operatingSystem; addHeader('user-agent', device); } diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index 2126620288..7a5e6303d2 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -60,6 +60,32 @@ abstract class Realtime extends Service { /// subscription when you want to tear everything down. Future disconnect(); + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// Fire-and-forget: returns void and does not await the server response. + /// Mirrors the `subscribe()` shape — call without `await`. Throws synchronously + /// if there is no open WebSocket connection. + /// + /// ```dart + /// realtime.subscribe(['account']); + /// await Future.delayed(Duration(seconds: 1)); // let the WS open + /// realtime.upsertPresence( + /// status: 'online', + /// presenceId: 'p-1', + /// metadata: {'device': 'web'}, + /// ); + /// ``` + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }); + /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. /// /// Before the connection has been closed, this will be `null`. diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index 11c7a4ffdf..4690ba6772 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -10,4 +10,12 @@ abstract class RealtimeBase implements Realtime { @override Future disconnect(); + + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }); } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index 8ae3c74033..3563e7d983 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -41,4 +41,19 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { }) { return subscribeTo(channels, queries); } + + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + upsertPresenceTo( + status: status, + presenceId: presenceId, + permissions: permissions, + metadata: metadata, + ); + } } diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 1735580b26..e8cd2ebb3d 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -50,6 +50,21 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { return subscribeTo(channels, queries); } + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + upsertPresenceTo( + status: status, + presenceId: presenceId, + permissions: permissions, + metadata: metadata, + ); + } + // https://github.com/jonataslaw/getsocket/blob/f25b3a264d8cc6f82458c949b86d286cd0343792/lib/src/io.dart#L104 // and from official dart sdk websocket_impl.dart connect method Future _connectForSelfSignedCert( diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 4eab921346..e03381ec94 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,6 +30,8 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; + Map? _pendingPresence; + bool _appConnected = false; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -44,6 +46,7 @@ mixin RealtimeMixin { Future _closeConnection() async { _stopHeartbeat(); + _appConnected = false; await _websocketSubscription?.cancel(); await _websok?.sink.close(status.normalClosure, 'Ending session'); _lastUrl = null; @@ -123,14 +126,11 @@ mixin RealtimeMixin { 'queries': entry.value.queries, }; } + _appConnected = true; _sendPendingSubscribes(); + _flushPendingPresence(); _startHeartbeat(); // Start heartbeat after successful connection break; - case 'response': - // The SDK generates subscriptionIds client-side and sends them on - // every subscribe/unsubscribe, so subscribe/unsubscribe acks carry - // no state the SDK needs to reconcile. - break; case 'pong': break; case 'event': @@ -153,9 +153,11 @@ mixin RealtimeMixin { break; } }, onDone: () { + _appConnected = false; _stopHeartbeat(); _retry(); }, onError: (err, stack) { + _appConnected = false; _stopHeartbeat(); for (var subscription in _subscriptions.values) { subscription.controller.addError(err, stack); @@ -257,6 +259,7 @@ mixin RealtimeMixin { } _subscriptions.clear(); _pendingSubscribes.clear(); + _pendingPresence = null; await _closeConnection(); _reconnect = true; // allow future subscribeTo() calls to reconnect } @@ -279,6 +282,14 @@ mixin RealtimeMixin { })); } + void _flushPendingPresence() { + final data = _pendingPresence; + if (data == null) return; + if (_websok == null || _websok?.closeCode != null) return; + if (!_appConnected) return; + _websok!.sink.add(jsonEncode({'type': 'presence', 'data': data})); + } + /// Convert channel value to string /// Handles String and Channel instances (which have toString()) String _channelToString(Object channel) { @@ -359,4 +370,25 @@ mixin RealtimeMixin { _retry(); } } + + /// Fire-and-forget presence upsert. Records the latest payload in state so + /// that — if the WebSocket isn't open yet, or later reconnects — the most + /// recent presence is automatically (re)sent on the next `connected` event. + /// When the socket is already open, the frame is sent immediately. + void upsertPresenceTo({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + final data = { + 'status': status, + 'presenceId': presenceId, + }; + if (permissions != null) data['permissions'] = permissions; + if (metadata != null) data['metadata'] = metadata; + + _pendingPresence = data; + _flushPendingPresence(); + } } \ No newline at end of file diff --git a/templates/flutter/test/src/channel_test.dart.twig b/templates/flutter/test/src/channel_test.dart.twig index 284d27f298..49f199460a 100644 --- a/templates/flutter/test/src/channel_test.dart.twig +++ b/templates/flutter/test/src/channel_test.dart.twig @@ -109,4 +109,38 @@ void main() { 'memberships.membership1.update'); }); }); + + group('presences()', () { + test('returns presences global channel', () { + expect(Channel.presences(), 'presences'); + }); + + test('throws when presence id is missing', () { + expect(() => Channel.presence(''), throwsArgumentError); + }); + + test('returns presence channel with specific presence ID', () { + expect(Channel.presence('presence1').toString(), 'presences.presence1'); + }); + + test('returns presence channel with create action', () { + expect(Channel.presence('presence1').create().toString(), + 'presences.presence1.create'); + }); + + test('returns presence channel with upsert action', () { + expect(Channel.presence('presence1').upsert().toString(), + 'presences.presence1.upsert'); + }); + + test('returns presence channel with update action', () { + expect(Channel.presence('presence1').update().toString(), + 'presences.presence1.update'); + }); + + test('returns presence channel with delete action', () { + expect(Channel.presence('presence1').delete().toString(), + 'presences.presence1.delete'); + }); + }); } diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig index 84056b4ea8..3260b6be84 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index eed33bc427..8c94daddbb 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -52,7 +52,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} * @param {{ paramDocType | raw }}|null ${{ property.name | caseCamel }} {{ property.description | unescape | lower | raw }} {% endfor %} {% if definition.additionalProperties %} - * @param array $data Additional properties. + * @param array ${{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }} Additional properties. {% endif %} */ public function __construct( @@ -63,7 +63,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} public ?{{ property | typeName }} ${{ property.name | caseCamel }} = null{{ (not loop.last or definition.additionalProperties) ? ',' : '' }} {% endfor %} {% if definition.additionalProperties %} - public array $data = [] + public array ${{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }} = [] {% endif %} ) { } @@ -140,7 +140,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - data: $additionalProperties + {{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }}: $additionalProperties {% endif %} ); {% endif %} @@ -161,7 +161,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} ]; {% if definition.additionalProperties %} - foreach (static::serializeAdditionalProperties($this->data) as $field => $value) { + foreach (static::serializeAdditionalProperties($this->{{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }}) as $field => $value) { $result[$field] = $value; } {% endif %} diff --git a/templates/python/package/models/model.py.twig b/templates/python/package/models/model.py.twig index 0ff5843e70..0789cc631e 100644 --- a/templates/python/package/models/model.py.twig +++ b/templates/python/package/models/model.py.twig @@ -70,7 +70,7 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener internal_fields = {k: v for k, v in data.items() if k.startswith('$')} user_data = {k: v for k, v in data.items() if not k.startswith('$')} instance = cls.model_validate(internal_fields) - instance._data = model_type(**user_data) if model_type is not dict else user_data + instance._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} = model_type(**user_data) if model_type is not dict else user_data return instance {% else %} instance = cls.model_validate(data) @@ -94,24 +94,24 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener {% endif %} {% if definition.additionalProperties %} - _data: Any = PrivateAttr(default_factory=dict) + _{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}: Any = PrivateAttr(default_factory=dict) @property - def data(self) -> T: - return cast(T, self._data) + def {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(self) -> T: + return cast(T, self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}) - @data.setter - def data(self, value: T) -> None: - object.__setattr__(self, '_data', value) + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.setter + def {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(self, value: T) -> None: + object.__setattr__(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}', value) def to_dict(self) -> Dict[str, Any]: result = super().to_dict() - if hasattr(self, '_data'): - if isinstance(self._data, dict): - result['data'] = self._data - elif hasattr(self._data, 'model_dump'): - result['data'] = self._data.model_dump(mode='json') + if hasattr(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}'): + if isinstance(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}, dict): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} + elif hasattr(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}, 'model_dump'): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.model_dump(mode='json') else: - result['data'] = self._data + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} return result {% endif %} diff --git a/templates/react-native/src/channel.ts.twig b/templates/react-native/src/channel.ts.twig index b6e4162104..653bd313ca 100644 --- a/templates/react-native/src/channel.ts.twig +++ b/templates/react-native/src/channel.ts.twig @@ -11,9 +11,10 @@ interface Func { _fn: any } interface Execution { _exec: any } interface Team { _team: any } interface Membership { _mem: any } +interface Presence { _presence: any } interface Resolved { _res: any } -type Actionable = Document | Row | File | Team | Membership; +type Actionable = Document | Row | File | Team | Membership | Presence; function normalize(id: string): string { if (id === undefined || id === null) { @@ -79,7 +80,7 @@ export class Channel { return this.resolve("create"); } - upsert(this: Channel): Channel { + upsert(this: Channel): Channel { return this.resolve("upsert"); } @@ -120,6 +121,10 @@ export class Channel { return new Channel(["memberships", normalize(id)]); } + static presence(id: string) { + return new Channel(["presences", normalize(id)]); + } + static account(): string { return "account"; } @@ -148,8 +153,12 @@ export class Channel { static memberships(): string { return "memberships"; } + + static presences(): string { + return "presences"; + } } // Export types for backward compatibility with realtime -export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel | Channel; export type ResolvedChannel = Channel; diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index 0163b0614c..e0540feaac 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -8,7 +8,7 @@ module {{ spec.title | caseUcfirst }} attr_reader :{{ property.name | caseSnake | escapeKeyword }} {% endfor %} {% if definition.additionalProperties %} - attr_reader :data + attr_reader :{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} def initialize( @@ -17,7 +17,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}: {% endif %} ) {% for property in definition.properties %} @@ -32,7 +32,7 @@ module {{ spec.title | caseUcfirst }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - @data = data + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} end @@ -43,7 +43,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: map["data"] || map + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}: map["{{ definition.additionalPropertiesKey | default('data') }}"] || map {% endif %} ) end @@ -55,14 +55,14 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - "data": @data + "{{ definition.additionalPropertiesKey | default('data') }}": @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} } end {% if definition.additionalProperties %} def convert_to(from_json) - from_json.call(data) + from_json.call({{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}) end {% endif %} {% for property in definition.properties %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig index c973cdbd2f..86e5c21e78 100644 --- a/templates/rust/src/models/model.rs.twig +++ b/templates/rust/src/models/model.rs.twig @@ -26,7 +26,7 @@ pub struct {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} #[serde(flatten)] - pub data: HashMap, + pub {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}: HashMap, {% endif %} } @@ -57,12 +57,12 @@ impl {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} pub fn get(&self, key: &str) -> Option { - self.data.get(key) + self.{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()) } - pub fn data(&self) -> &HashMap { - &self.data + pub fn {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(&self) -> &HashMap { + &self.{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} } {% endif %} } diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index e06da13dc5..730faf7fa7 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -15,7 +15,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { case {{ property.name | escapeSwiftKeyword | removeDollarSign }} = "{{ property.name }}" {%~ endfor %} {%~ if definition.additionalProperties %} - case data + case {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = "{{ definition.additionalPropertiesKey | default('data') }}" {%~ endif %} } @@ -26,7 +26,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} /// Additional properties - public let data: T + public let {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} init( @@ -35,14 +35,14 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: T + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} ) { {%~ for property in definition.properties %} self.{{ property.name | escapeSwiftKeyword | removeDollarSign }} = {{ property.name | escapeSwiftKeyword | removeDollarSign }} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = data + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} {%~ endif %} } @@ -76,7 +76,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endif %} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = try container.decode(T.self, forKey: .data) + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = try container.decode(T.self, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -87,7 +87,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { try container.encode{% if not property.required %}IfPresent{% endif %}({{ property.name | escapeSwiftKeyword | removeDollarSign }}{% if property.enum %}{% if property.required %}.rawValue{% else %}?.rawValue{% endif %}{% elseif property.enumValues %}{% if property.required %}.map { $0.rawValue }{% else %}?.map { $0.rawValue }{% endif %}{% endif %}, forKey: .{{ property.name | escapeSwiftKeyword | removeDollarSign }}) {%~ endfor %} {%~ if definition.additionalProperties %} - try container.encode(data, forKey: .data) + try container.encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -98,7 +98,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - "data": try! JSONEncoder().encode(data) + "{{ definition.additionalPropertiesKey | default('data') }}": try! JSONEncoder().encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} ] } @@ -129,7 +129,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["data"] as? [String: Any] ?? map, options: [])) + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["{{ definition.additionalPropertiesKey | default('data') }}"] as? [String: Any] ?? map, options: [])) {%~ endif %} ) } diff --git a/templates/web/src/channel.ts.twig b/templates/web/src/channel.ts.twig index a5d7ee38e9..d4618a360a 100644 --- a/templates/web/src/channel.ts.twig +++ b/templates/web/src/channel.ts.twig @@ -11,9 +11,10 @@ interface Func { _fn: any } interface Execution { _exec: any } interface Team { _team: any } interface Membership { _mem: any } +interface Presence { _presence: any } interface Resolved { _res: any } -type Actionable = Document | Row | File | Team | Membership; +type Actionable = Document | Row | File | Team | Membership | Presence; function normalize(id: string): string { if (id === undefined || id === null) { @@ -82,7 +83,7 @@ export class Channel { return this.resolve("create"); } - upsert(this: Channel): Channel { + upsert(this: Channel): Channel { return this.resolve("upsert"); } @@ -123,6 +124,10 @@ export class Channel { return new Channel(["memberships", normalize(id)]); } + static presence(id: string) { + return new Channel(["presences", normalize(id)]); + } + static account(): string { return "account"; } @@ -151,8 +156,12 @@ export class Channel { static memberships(): string { return "memberships"; } + + static presences(): string { + return "presences"; + } } // Export types for backward compatibility with realtime -export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel | Channel; export type ResolvedChannel = Channel; diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index ec7d3cf593..dff76607ca 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -55,10 +55,31 @@ export type RealtimeResponseConnected = { } export type RealtimeRequest = { - type: 'authentication' | 'subscribe' | 'unsubscribe'; + type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence'; data: any; } +export type RealtimePresence = { + $id: string; + $sequence?: string | number; + $createdAt: string; + $updatedAt: string; + $permissions: string[]; + userInternalId: string; + userId: string; + status?: string; + source: string; + expiry?: string; + metadata?: Record; +} + +export type RealtimePresenceCreate = { + status: string; + presenceId: string; + permissions?: string[]; + metadata?: Record; +} + type RealtimeRequestSubscribeRow = { subscriptionId?: string; channels: string[]; @@ -76,7 +97,6 @@ export class Realtime { private readonly TYPE_EVENT = 'event'; private readonly TYPE_PONG = 'pong'; private readonly TYPE_CONNECTED = 'connected'; - private readonly TYPE_RESPONSE = 'response'; private readonly DEBOUNCE_MS = 1; private readonly HEARTBEAT_INTERVAL = 20000; // 20 seconds in milliseconds @@ -84,6 +104,8 @@ export class Realtime { private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); + private pendingPresence?: Record; + private appConnected = false; private heartbeatTimer?: number; private subCallDepth = 0; @@ -218,6 +240,7 @@ export class Realtime { if (connectionId !== this.connectionId || socket !== this.socket) { return; } + this.appConnected = false; this.stopHeartbeat(); this.onCloseCallbacks.forEach(callback => callback()); @@ -338,6 +361,8 @@ export class Realtime { public async disconnect(): Promise { this.activeSubscriptions.clear(); this.pendingSubscribes.clear(); + this.pendingPresence = undefined; + this.appConnected = false; this.reconnect = false; await this.closeSocket(); } @@ -544,6 +569,50 @@ export class Realtime { return { unsubscribe, update, close }; } + /** + * Fire-and-forget presence upsert. Records the latest payload in state so + * that — if the WebSocket isn't open yet, or later reconnects — only the + * most recent presence is automatically (re)sent on the next `connected` + * event. Repeated calls while the socket is closed collapse to the latest + * payload (older ones are discarded). + * + * Returns a `Promise` for API consistency; the promise resolves as + * soon as the payload has been stored and the opportunistic send attempted. + * + * @param {RealtimePresenceCreate} params - Presence payload (status and presenceId required, permissions/metadata optional) + */ + public async upsertPresence(params: RealtimePresenceCreate): Promise { + const data: Record = { + status: params.status, + presenceId: params.presenceId, + }; + if (params.permissions !== undefined) { + data.permissions = params.permissions; + } + if (params.metadata !== undefined) { + data.metadata = params.metadata; + } + + this.pendingPresence = data; + this.flushPendingPresence(); + } + + private flushPendingPresence(): void { + if (!this.pendingPresence) { + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return; + } + if (!this.appConnected) { + return; + } + this.socket.send(JSONbig.stringify({ + type: 'presence', + data: this.pendingPresence + })); + } + private handleMessage(message: RealtimeResponse): void { if (!message.type) { return; @@ -562,9 +631,6 @@ export class Realtime { case this.TYPE_PONG: // Handle pong response if needed break; - case this.TYPE_RESPONSE: - this.handleResponseAction(message); - break; } } @@ -597,7 +663,9 @@ export class Realtime { for (const subscriptionId of this.activeSubscriptions.keys()) { this.enqueuePendingSubscribe(subscriptionId); } + this.appConnected = true; this.sendPendingSubscribes(); + this.flushPendingPresence(); } private handleResponseError(message: RealtimeResponse): void { @@ -638,10 +706,4 @@ export class Realtime { }); } } - - private handleResponseAction(_message: RealtimeResponse): void { - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. - } } diff --git a/tests/Base.php b/tests/Base.php index 74c7a6ee6f..17afba396b 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -115,6 +115,7 @@ abstract class Base extends TestCase 'Realtime failed!', 'Realtime unsubscribe:passed', 'Realtime update:passed', + 'Realtime presence:passed', 'Realtime disconnect:passed', ]; @@ -287,6 +288,12 @@ abstract class Base extends TestCase 'memberships.membership2', 'memberships.membership1', 'memberships.membership1.update', + 'presences', + 'presences.presence2', + 'presences.presence1', + 'presences.presence1.upsert', + 'presences.presence1.update', + 'presences.presence1.delete', ]; protected const OPERATOR_HELPER_RESPONSES = [ diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index d32e0ed6a4..0a79dbd700 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -81,7 +81,7 @@ class ServiceTest { // reset configs client.setProject("console") - .setEndpointRealtime("wss://cloud.appwrite.io/v1") + .setEndpointRealtime("ws://mockapi/v1") val foo = Foo(client) val bar = Bar(client) @@ -262,6 +262,22 @@ class ServiceTest { writeToFile("Realtime update:failed") } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (no suspend, no await). + try { + realtime.upsertPresence( + status = "online", + presenceId = "p-test", + metadata = mapOf("page" to "/home"), + ) + delay(1000) + + writeToFile("Realtime presence:passed") + } catch (e: Exception) { + writeToFile("Realtime presence:failed") + } + try { realtime.disconnect() writeToFile("Realtime disconnect:passed") @@ -397,6 +413,12 @@ class ServiceTest { writeToFile(Channel.membership("membership2").toString()) writeToFile(Channel.membership("membership1").toString()) writeToFile(Channel.membership("membership1").update().toString()) + writeToFile(Channel.presences()) + writeToFile(Channel.presence("presence2").toString()) + writeToFile(Channel.presence("presence1").toString()) + writeToFile(Channel.presence("presence1").upsert().toString()) + writeToFile(Channel.presence("presence1").update().toString()) + writeToFile(Channel.presence("presence1").delete().toString()) // Operator helper tests writeToFile(Operator.increment(1)) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index ed1314b328..a3cb511039 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -34,8 +34,8 @@ class Tests: XCTestCase { // reset configs client.setProject("console") - client.setEndpointRealtime("wss://cloud.appwrite.io/v1") - client.setSelfSigned(false) + client.setEndpointRealtime("ws://mockapi/v1") + // Keep selfSigned=true so WebSocketClient skips TLS for the ws:// mock endpoint. let foo = Foo(client) let bar = Bar(client) @@ -196,13 +196,13 @@ class Tests: XCTestCase { print("Invalid endpoint URL: htp://cloud.appwrite.io/v1") // Indicates fatalError by client.setEndpoint - wait(for: [expectation], timeout: 20.0) + await fulfillment(of: [expectation], timeout: 20.0) print(realtimeResponse) - wait(for: [expectationWithQueries], timeout: 20.0) + await fulfillment(of: [expectationWithQueries], timeout: 20.0) print(realtimeResponseWithQueries) - - wait(for: [expectationWithQueriesFailure], timeout: 20.0) + + await fulfillment(of: [expectationWithQueriesFailure], timeout: 20.0) if expectationWithQueriesFailure.isInverted { print(realtimeResponseWithQueriesFailure) } else { @@ -236,6 +236,22 @@ class Tests: XCTestCase { print("Realtime update:failed") } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (no `try await`). + do { + try realtime.upsertPresence( + status: "online", + presenceId: "p-test", + metadata: ["page": "/home"] + ) + try await Task.sleep(nanoseconds: 1_000_000_000) + + print("Realtime presence:passed") + } catch { + print("Realtime presence:failed") + } + do { try await realtime.disconnect() print("Realtime disconnect:passed") @@ -377,6 +393,12 @@ class Tests: XCTestCase { print(try Channel.membership("membership2").toString()) print(try Channel.membership("membership1").toString()) print(try Channel.membership("membership1").update().toString()) + print(Channel.presences()) + print(try Channel.presence("presence2").toString()) + print(try Channel.presence("presence1").toString()) + print(try Channel.presence("presence1").upsert().toString()) + print(try Channel.presence("presence1").update().toString()) + print(try Channel.presence("presence1").delete().toString()) // Operator helper tests print(Operator.increment(1)) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 913ed43aaf..14b2cc20ea 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -24,8 +24,7 @@ void main() async { PathProviderPlatform.instance = FakePathProvider(); Client client = Client() .setProject('123456') - .addHeader("Origin", "http://localhost") - .setSelfSigned(); + .addHeader("Origin", "http://localhost"); Foo foo = Foo(client); Bar bar = Bar(client); @@ -33,7 +32,7 @@ void main() async { client.setSelfSigned(); client.setProject('console'); - client.setEndPointRealtime("wss://cloud.appwrite.io/v1"); + client.setEndPointRealtime("ws://mockapi/v1"); Realtime realtime = Realtime(client); // Subscribe without queries @@ -54,6 +53,24 @@ void main() async { ], ); + // Attach listeners immediately so the broadcast streams don't drop the + // synthetic event the mock emits right after subscribe() (broadcast + // streams don't buffer events while there's no listener attached). + final rtsubFirst = Completer(); + rtsub.stream.listen((m) { + if (!rtsubFirst.isCompleted) rtsubFirst.complete(m); + }); + + final rtsubWithQueriesFirst = Completer(); + rtsubWithQueries.stream.listen((m) { + if (!rtsubWithQueriesFirst.isCompleted) rtsubWithQueriesFirst.complete(m); + }); + + final rtsubWithQueriesFailureFirst = Completer(); + rtsubWithQueriesFailure.stream.listen((m) { + if (!rtsubWithQueriesFailureFirst.isCompleted) rtsubWithQueriesFailureFirst.complete(m); + }); + await Future.delayed(Duration(seconds: 5)); client.addHeader('Origin', 'http://localhost'); print('\nTest Started'); @@ -173,15 +190,17 @@ void main() async { print(e.message); } - // Assert realtime outputs in a deterministic order (no-query then with-query) - final message1 = await rtsub.stream.first.timeout(Duration(seconds: 10)); + // Assert realtime outputs in a deterministic order (no-query then with-query). + // Listeners were attached right after subscribe() above, so messages that + // arrived during the HTTP-test block have already been captured. + final message1 = await rtsubFirst.future.timeout(Duration(seconds: 10)); print(message1.payload["response"]); - final message2 = await rtsubWithQueries.stream.first.timeout(Duration(seconds: 10)); + final message2 = await rtsubWithQueriesFirst.future.timeout(Duration(seconds: 10)); print(message2.payload["response"]); try { - final message3 = await rtsubWithQueriesFailure.stream.first.timeout(Duration(seconds: 10)); + final message3 = await rtsubWithQueriesFailureFirst.future.timeout(Duration(seconds: 10)); // If we receive a message, it means the query filtering failed, so realtime failed print("Realtime failed!"); } on TimeoutException { @@ -213,6 +232,22 @@ void main() async { print("Realtime update:failed"); } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (void), no await needed. + try { + realtime.upsertPresence( + status: 'online', + presenceId: 'p-test', + metadata: {'page': '/home'}, + ); + await Future.delayed(Duration(seconds: 1)); + + print("Realtime presence:passed"); + } catch (e) { + print("Realtime presence:failed"); + } + try { await realtime.disconnect(); print("Realtime disconnect:passed"); @@ -354,6 +389,12 @@ void main() async { print(Channel.membership('membership2').toString()); print(Channel.membership('membership1').toString()); print(Channel.membership('membership1').update().toString()); + print(Channel.presences()); + print(Channel.presence('presence2').toString()); + print(Channel.presence('presence1').toString()); + print(Channel.presence('presence1').upsert().toString()); + print(Channel.presence('presence1').update().toString()); + print(Channel.presence('presence1').delete().toString()); // Operator helper tests print(Operator.increment(1)); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index a953a6b6b0..d46626713e 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -45,7 +45,7 @@ // Realtime setup client.setProject('console'); - client.setEndpointRealtime('wss://cloud.appwrite.io/v1'); + client.setEndpointRealtime('ws://mockapi/v1'); const realtime = new Realtime(client); const realtimeWithFailure = new Realtime(client); @@ -319,6 +319,22 @@ console.log('Realtime update:failed'); } + // Realtime presence (upsertPresence) test. Rides the existing + // WebSocket opened by the main realtime tests above — upsertPresence + // is fire-and-forget (Promise resolves immediately). + try { + await realtime.upsertPresence({ + status: 'online', + presenceId: 'p-test', + metadata: { page: '/home' }, + }); + await new Promise(resolve => setTimeout(resolve, 1000)); + + console.log('Realtime presence:passed'); + } catch (e) { + console.log('Realtime presence:failed'); + } + try { await realtime.disconnect(); console.log('Realtime disconnect:passed'); @@ -453,6 +469,12 @@ console.log(Channel.membership('membership2').toString()); console.log(Channel.membership('membership1').toString()); console.log(Channel.membership('membership1').update().toString()); + console.log(Channel.presences()); + console.log(Channel.presence('presence2').toString()); + console.log(Channel.presence('presence1').toString()); + console.log(Channel.presence('presence1').upsert().toString()); + console.log(Channel.presence('presence1').update().toString()); + console.log(Channel.presence('presence1').delete().toString()); // Operator helper tests console.log(Operator.increment(1)); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index 340a779a1b..8214c03d31 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -192,6 +192,7 @@ async function start() { console.log('Realtime failed!'); // Skip realtime query failure test on Node.js console.log('Realtime unsubscribe:passed'); // Skip new realtime API tests on Node.js console.log('Realtime update:passed'); + console.log('Realtime presence:passed'); // Skip realtime presence test on Node.js console.log('Realtime disconnect:passed'); // Query helper tests @@ -321,6 +322,12 @@ async function start() { console.log(Channel.membership('membership2').toString()); console.log(Channel.membership('membership1').toString()); console.log(Channel.membership('membership1').update().toString()); + console.log(Channel.presences()); + console.log(Channel.presence('presence2').toString()); + console.log(Channel.presence('presence1').toString()); + console.log(Channel.presence('presence1').upsert().toString()); + console.log(Channel.presence('presence1').update().toString()); + console.log(Channel.presence('presence1').delete().toString()); // Operator helper tests console.log(Operator.increment(1));