mirror of
https://github.com/appwrite/appwrite
synced 2026-04-21 13:37:16 +00:00
Merge remote-tracking branch 'origin/1.9.x' into fix/realtime-console-test-flake
This commit is contained in:
commit
bee6050cb4
15 changed files with 761 additions and 221 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -210,7 +210,7 @@ jobs:
|
|||
with:
|
||||
script: |
|
||||
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
|
||||
const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
|
||||
const allModes = ['dedicated', 'shared'];
|
||||
|
||||
const defaultDatabases = ['MongoDB'];
|
||||
const defaultModes = ['dedicated'];
|
||||
|
|
@ -479,11 +479,8 @@ jobs:
|
|||
env:
|
||||
_APP_BROWSER_HOST: http://invalid-browser/v1
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
|
|
@ -557,11 +554,8 @@ jobs:
|
|||
env:
|
||||
_APP_OPTIONS_ABUSE: enabled
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
|
|
@ -618,11 +612,8 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
env:
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
|
||||
_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
|
|
|
|||
|
|
@ -892,7 +892,7 @@
|
|||
* Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978
|
||||
* Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005
|
||||
* Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011
|
||||
* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013
|
||||
* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013
|
||||
* Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018
|
||||
|
||||
# Version 1.6.0
|
||||
|
|
|
|||
|
|
@ -300,6 +300,26 @@ return [
|
|||
'repoBranch' => 'main',
|
||||
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'),
|
||||
],
|
||||
[
|
||||
'key' => 'claude-plugin',
|
||||
'name' => 'ClaudePlugin',
|
||||
'version' => '0.1.0',
|
||||
'url' => 'https://github.com/appwrite/claude-plugin.git',
|
||||
'enabled' => true,
|
||||
'beta' => false,
|
||||
'dev' => false,
|
||||
'hidden' => false,
|
||||
'spec' => 'static',
|
||||
'family' => APP_SDK_PLATFORM_STATIC,
|
||||
'prism' => 'claude-plugin',
|
||||
'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'),
|
||||
'gitUrl' => 'git@github.com:appwrite/claude-plugin.git',
|
||||
'gitRepoName' => 'claude-plugin',
|
||||
'gitUserName' => 'appwrite',
|
||||
'gitBranch' => 'dev',
|
||||
'repoBranch' => 'main',
|
||||
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
20
app/http.php
20
app/http.php
|
|
@ -413,27 +413,19 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
|
|||
$projectCollections = $collections['projects'];
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
$sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1);
|
||||
|
||||
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
|
||||
$documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''));
|
||||
$documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1);
|
||||
|
||||
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
|
||||
$vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''));
|
||||
$vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1);
|
||||
|
||||
$cache = $app->getResource('cache');
|
||||
|
||||
// All shared tables V2 pools that need project metadata collections
|
||||
$sharedTablesV2All = \array_values(\array_unique(\array_filter([
|
||||
...$sharedTablesV2,
|
||||
...$documentsSharedTablesV2,
|
||||
...$vectorSharedTablesV2,
|
||||
// All shared tables pools that need project metadata collections
|
||||
$allSharedTables = \array_values(\array_unique(\array_filter([
|
||||
...$sharedTables,
|
||||
...$documentsSharedTables,
|
||||
...$vectorSharedTables,
|
||||
])));
|
||||
|
||||
foreach ($sharedTablesV2All as $hostname) {
|
||||
foreach ($allSharedTables as $hostname) {
|
||||
Span::init('database.setup');
|
||||
Span::add('database.hostname', $hostname);
|
||||
|
||||
|
|
|
|||
|
|
@ -1070,9 +1070,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
|||
$realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries);
|
||||
}
|
||||
|
||||
// subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context.
|
||||
$realtime->connections[$connection]['authorization'] = $authorization;
|
||||
|
||||
$responsePayload = json_encode([
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
|
|
@ -1102,6 +1099,58 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
|||
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
if (!\is_array($message['data']) || !\array_is_list($message['data'])) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
|
||||
}
|
||||
|
||||
// Validate every payload before executing any removal so an invalid entry
|
||||
// later in the batch does not leave earlier entries half-applied on the server.
|
||||
$validatedIds = [];
|
||||
foreach ($message['data'] as $payload) {
|
||||
if (
|
||||
!\is_array($payload)
|
||||
|| !\array_key_exists('subscriptionId', $payload)
|
||||
|| !\is_string($payload['subscriptionId'])
|
||||
|| $payload['subscriptionId'] === ''
|
||||
) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.');
|
||||
}
|
||||
$validatedIds[] = $payload['subscriptionId'];
|
||||
}
|
||||
|
||||
$unsubscribeResults = [];
|
||||
foreach ($validatedIds as $subscriptionId) {
|
||||
$wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId);
|
||||
$unsubscribeResults[] = [
|
||||
'subscriptionId' => $subscriptionId,
|
||||
'removed' => $wasRemoved,
|
||||
];
|
||||
}
|
||||
|
||||
$unsubscribeResponsePayload = json_encode([
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'unsubscribe',
|
||||
'success' => true,
|
||||
'subscriptions' => $unsubscribeResults,
|
||||
],
|
||||
]);
|
||||
|
||||
$server->send([$connection], $unsubscribeResponsePayload);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
|
||||
|
||||
if ($unsubscribeOutboundBytes > 0) {
|
||||
triggerStats([
|
||||
METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes,
|
||||
], $project->getId());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
|
|
|
|||
133
composer.lock
generated
133
composer.lock
generated
|
|
@ -2887,7 +2887,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
|
|
@ -2948,7 +2948,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2972,7 +2972,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php82",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php82.git",
|
||||
|
|
@ -3028,7 +3028,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3052,7 +3052,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
|
|
@ -3108,7 +3108,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3132,7 +3132,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php85",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php85.git",
|
||||
|
|
@ -3188,7 +3188,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4271,16 +4271,16 @@
|
|||
},
|
||||
{
|
||||
"name": "utopia-php/http",
|
||||
"version": "0.34.20",
|
||||
"version": "0.34.21",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/http.git",
|
||||
"reference": "d6b360d555022d16c16d40be51f86180364819f8"
|
||||
"reference": "49a6bd3ea0d2966aa19cf707255d442675288a24"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8",
|
||||
"reference": "d6b360d555022d16c16d40be51f86180364819f8",
|
||||
"url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24",
|
||||
"reference": "49a6bd3ea0d2966aa19cf707255d442675288a24",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -4319,22 +4319,22 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/http/issues",
|
||||
"source": "https://github.com/utopia-php/http/tree/0.34.20"
|
||||
"source": "https://github.com/utopia-php/http/tree/0.34.21"
|
||||
},
|
||||
"time": "2026-04-12T14:25:22+00:00"
|
||||
"time": "2026-04-19T19:44:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/image",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/image.git",
|
||||
"reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3"
|
||||
"reference": "85ab7027873e11bc901110d8f7830252247ba724"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3",
|
||||
"reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3",
|
||||
"url": "https://api.github.com/repos/utopia-php/image/zipball/85ab7027873e11bc901110d8f7830252247ba724",
|
||||
"reference": "85ab7027873e11bc901110d8f7830252247ba724",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -4370,9 +4370,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/image/issues",
|
||||
"source": "https://github.com/utopia-php/image/tree/0.8.5"
|
||||
"source": "https://github.com/utopia-php/image/tree/0.8.6"
|
||||
},
|
||||
"time": "2026-04-17T15:02:49+00:00"
|
||||
"time": "2026-04-19T12:52:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/locale",
|
||||
|
|
@ -5464,16 +5464,16 @@
|
|||
"packages-dev": [
|
||||
{
|
||||
"name": "appwrite/sdk-generator",
|
||||
"version": "1.17.11",
|
||||
"version": "1.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/appwrite/sdk-generator.git",
|
||||
"reference": "c714ee52659ef5968b3372ff4da0e407140a6250"
|
||||
"reference": "525f0630520c95100fcdfb63c9dac859c1d02588"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250",
|
||||
"reference": "c714ee52659ef5968b3372ff4da0e407140a6250",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588",
|
||||
"reference": "525f0630520c95100fcdfb63c9dac859c1d02588",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -5509,9 +5509,9 @@
|
|||
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
|
||||
"support": {
|
||||
"issues": "https://github.com/appwrite/sdk-generator/issues",
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.17.11"
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.20"
|
||||
},
|
||||
"time": "2026-04-11T02:42:32+00:00"
|
||||
"time": "2026-04-20T05:45:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brianium/paratest",
|
||||
|
|
@ -6220,11 +6220,11 @@
|
|||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.46",
|
||||
"version": "2.1.50",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
|
||||
"reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2",
|
||||
"reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -6269,20 +6269,20 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T09:25:14+00:00"
|
||||
"time": "2026-04-17T13:10:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "12.5.3",
|
||||
"version": "12.5.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
||||
"reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d"
|
||||
"reference": "876099a072646c7745f673d7aeab5382c4439691"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d",
|
||||
"reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
|
||||
"reference": "876099a072646c7745f673d7aeab5382c4439691",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -6291,7 +6291,6 @@
|
|||
"ext-xmlwriter": "*",
|
||||
"nikic/php-parser": "^5.7.0",
|
||||
"php": ">=8.3",
|
||||
"phpunit/php-file-iterator": "^6.0",
|
||||
"phpunit/php-text-template": "^5.0",
|
||||
"sebastian/complexity": "^5.0",
|
||||
"sebastian/environment": "^8.0.3",
|
||||
|
|
@ -6338,7 +6337,7 @@
|
|||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
|
||||
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3"
|
||||
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -6358,7 +6357,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-06T06:01:44+00:00"
|
||||
"time": "2026-04-15T08:23:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-file-iterator",
|
||||
|
|
@ -6619,16 +6618,16 @@
|
|||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.5.17",
|
||||
"version": "12.5.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "85b62adab1a340982df64e66daa4a4435eb5723b"
|
||||
"reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b",
|
||||
"reference": "85b62adab1a340982df64e66daa4a4435eb5723b",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
|
||||
"reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -6642,15 +6641,15 @@
|
|||
"phar-io/manifest": "^2.0.4",
|
||||
"phar-io/version": "^3.2.1",
|
||||
"php": ">=8.3",
|
||||
"phpunit/php-code-coverage": "^12.5.3",
|
||||
"phpunit/php-code-coverage": "^12.5.6",
|
||||
"phpunit/php-file-iterator": "^6.0.1",
|
||||
"phpunit/php-invoker": "^6.0.0",
|
||||
"phpunit/php-text-template": "^5.0.0",
|
||||
"phpunit/php-timer": "^8.0.0",
|
||||
"sebastian/cli-parser": "^4.2.0",
|
||||
"sebastian/comparator": "^7.1.4",
|
||||
"sebastian/comparator": "^7.1.6",
|
||||
"sebastian/diff": "^7.0.0",
|
||||
"sebastian/environment": "^8.0.4",
|
||||
"sebastian/environment": "^8.1.0",
|
||||
"sebastian/exporter": "^7.0.2",
|
||||
"sebastian/global-state": "^8.0.2",
|
||||
"sebastian/object-enumerator": "^7.0.0",
|
||||
|
|
@ -6697,7 +6696,7 @@
|
|||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -6705,7 +6704,7 @@
|
|||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-08T03:04:19+00:00"
|
||||
"time": "2026-04-18T06:12:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
|
@ -6778,16 +6777,16 @@
|
|||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "7.1.5",
|
||||
"version": "7.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63"
|
||||
"reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63",
|
||||
"reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
|
||||
"reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -6846,7 +6845,7 @@
|
|||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -6866,7 +6865,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-08T04:43:00+00:00"
|
||||
"time": "2026-04-14T08:23:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/complexity",
|
||||
|
|
@ -6995,16 +6994,16 @@
|
|||
},
|
||||
{
|
||||
"name": "sebastian/environment",
|
||||
"version": "8.0.4",
|
||||
"version": "8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/environment.git",
|
||||
"reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
|
||||
"reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
|
||||
"reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
|
||||
"reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -7019,7 +7018,7 @@
|
|||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "8.0-dev"
|
||||
"dev-main": "8.1-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -7047,7 +7046,7 @@
|
|||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/environment/issues",
|
||||
"security": "https://github.com/sebastianbergmann/environment/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -7067,7 +7066,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-15T07:05:40+00:00"
|
||||
"time": "2026-04-15T12:13:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
|
|
@ -7780,7 +7779,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
|
|
@ -7839,7 +7838,7 @@
|
|||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -7863,7 +7862,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-grapheme",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
|
||||
|
|
@ -7921,7 +7920,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -7945,7 +7944,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||
|
|
@ -8006,7 +8005,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -8030,7 +8029,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php81",
|
||||
"version": "v1.34.0",
|
||||
"version": "v1.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php81.git",
|
||||
|
|
@ -8086,7 +8085,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0"
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -242,7 +242,6 @@ services:
|
|||
- _APP_EXPERIMENT_LOGGING_PROVIDER
|
||||
- _APP_EXPERIMENT_LOGGING_CONFIG
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
- _APP_DATABASE_SHARED_TABLES_V1
|
||||
- _APP_DATABASE_SHARED_NAMESPACE
|
||||
- _APP_FUNCTIONS_CREATION_ABUSE_LIMIT
|
||||
- _APP_CUSTOM_DOMAIN_DENY_LIST
|
||||
|
|
@ -462,7 +461,6 @@ services:
|
|||
- _APP_EXECUTOR_SECRET
|
||||
- _APP_EXECUTOR_HOST
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
- _APP_DATABASE_SHARED_TABLES_V1
|
||||
- _APP_EMAIL_CERTIFICATES
|
||||
- _APP_MAINTENANCE_RETENTION_AUDIT
|
||||
- _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE
|
||||
|
|
|
|||
|
|
@ -114,14 +114,24 @@ class Realtime extends MessagingAdapter
|
|||
}
|
||||
}
|
||||
|
||||
// Keep userId from onOpen/authentication when provided.
|
||||
// Fallback to existing stored value for subsequent subscribe upserts.
|
||||
$this->connections[$identifier] = [
|
||||
// Union channels/roles across all subscriptions on the connection; overwriting would
|
||||
// leave getSubscriptionMetadata and full unsubscribe operating on stale state.
|
||||
$existing = $this->connections[$identifier] ?? [];
|
||||
$existingChannels = $existing['channels'] ?? [];
|
||||
$existingRoles = $existing['roles'] ?? [];
|
||||
|
||||
$entry = [
|
||||
'projectId' => $projectId,
|
||||
'roles' => $roles,
|
||||
'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''),
|
||||
'channels' => $channels
|
||||
'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
|
||||
'userId' => $userId ?? ($existing['userId'] ?? ''),
|
||||
'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
|
||||
];
|
||||
|
||||
if (\array_key_exists('authorization', $existing)) {
|
||||
$entry['authorization'] = $existing['authorization'];
|
||||
}
|
||||
|
||||
$this->connections[$identifier] = $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -206,6 +216,87 @@ class Realtime extends MessagingAdapter
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single subscription from a connection, keeping the connection alive so
|
||||
* the client can resubscribe. Idempotent — returns true only when something was removed.
|
||||
*
|
||||
* @param mixed $connection
|
||||
* @param string $subscriptionId
|
||||
* @return bool
|
||||
*/
|
||||
public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool
|
||||
{
|
||||
$projectId = $this->connections[$connection]['projectId'] ?? '';
|
||||
if ($projectId === '' || !isset($this->subscriptions[$projectId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$removed = false;
|
||||
|
||||
foreach ($this->subscriptions[$projectId] as $role => $byChannel) {
|
||||
foreach ($byChannel as $channel => $byConnection) {
|
||||
if (!isset($byConnection[$connection][$subscriptionId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]);
|
||||
$removed = true;
|
||||
|
||||
if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) {
|
||||
unset($this->subscriptions[$projectId][$role][$channel][$connection]);
|
||||
}
|
||||
if (empty($this->subscriptions[$projectId][$role][$channel])) {
|
||||
unset($this->subscriptions[$projectId][$role][$channel]);
|
||||
}
|
||||
}
|
||||
if (empty($this->subscriptions[$projectId][$role])) {
|
||||
unset($this->subscriptions[$projectId][$role]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->subscriptions[$projectId])) {
|
||||
unset($this->subscriptions[$projectId]);
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
$this->recomputeConnectionState($connection);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the cached channels on the connection entry from the subscriptions tree.
|
||||
* Called after per-subscription removal so stale channel entries do not linger for later reads.
|
||||
*
|
||||
* Roles are deliberately NOT recomputed here. They represent the connection's authorization
|
||||
* context (set at onOpen, replaced on `authentication` / permission-change) and must survive
|
||||
* per-subscription removal — otherwise a client that unsubscribes every subscription and then
|
||||
* resubscribes would subscribe with an empty roles array and silently receive nothing.
|
||||
*
|
||||
* @param mixed $connection
|
||||
* @return void
|
||||
*/
|
||||
private function recomputeConnectionState(mixed $connection): void
|
||||
{
|
||||
if (!isset($this->connections[$connection])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectId = $this->connections[$connection]['projectId'] ?? '';
|
||||
$channels = [];
|
||||
|
||||
foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) {
|
||||
foreach ($byChannel as $channel => $byConnection) {
|
||||
if (isset($byConnection[$connection])) {
|
||||
$channels[$channel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->connections[$connection]['channels'] = \array_keys($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Channel has a subscriber.
|
||||
* @param string $projectId
|
||||
|
|
|
|||
|
|
@ -49,11 +49,6 @@ class Create extends Action
|
|||
$databaseOverride = '';
|
||||
$dbScheme = '';
|
||||
$databaseSharedTables = [];
|
||||
$databaseSharedTablesV1 = [];
|
||||
$databaseSharedTablesV2 = [];
|
||||
$projectSharedTables = [];
|
||||
$projectSharedTablesV1 = [];
|
||||
$projectSharedTablesV2 = [];
|
||||
|
||||
switch ($databasetype) {
|
||||
case DOCUMENTSDB:
|
||||
|
|
@ -62,7 +57,6 @@ class Create extends Action
|
|||
$databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE');
|
||||
$dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb');
|
||||
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')));
|
||||
$databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')));
|
||||
break;
|
||||
case VECTORSDB:
|
||||
$databases = Config::getParam('pools-vectorsdb', []);
|
||||
|
|
@ -70,7 +64,6 @@ class Create extends Action
|
|||
$databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE');
|
||||
$dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql');
|
||||
$databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')));
|
||||
$databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')));
|
||||
break;
|
||||
default:
|
||||
// legacy/tablesdb
|
||||
|
|
@ -78,8 +71,7 @@ class Create extends Action
|
|||
return $dsn;
|
||||
}
|
||||
|
||||
$isSharedTablesV1 = false;
|
||||
$isSharedTablesV2 = false;
|
||||
$isSharedTables = false;
|
||||
|
||||
if (!empty($dsn)) {
|
||||
try {
|
||||
|
|
@ -90,10 +82,7 @@ class Create extends Action
|
|||
}
|
||||
|
||||
$projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
$projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1);
|
||||
$isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1);
|
||||
$isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2);
|
||||
$isSharedTables = \in_array($dsnHost, $projectSharedTables);
|
||||
}
|
||||
|
||||
if ($region !== 'default') {
|
||||
|
|
@ -102,18 +91,14 @@ class Create extends Action
|
|||
return str_contains($value, $region);
|
||||
});
|
||||
}
|
||||
$databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1);
|
||||
|
||||
$index = \array_search($databaseOverride, $databases);
|
||||
if ($index !== false) {
|
||||
$selectedDsn = $databases[$index];
|
||||
} else {
|
||||
if (!empty($dsn) && !empty($databaseSharedTables)) {
|
||||
$beforeFilter = \array_values($databases);
|
||||
if ($isSharedTablesV1) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1));
|
||||
} elseif ($isSharedTablesV2) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2));
|
||||
if ($isSharedTables) {
|
||||
$databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables));
|
||||
} else {
|
||||
$databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ use Utopia\Database\DateTime;
|
|||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\DSN\DSN;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
|
@ -209,32 +207,16 @@ class Create extends Action
|
|||
}
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
|
||||
$sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
|
||||
$sharedTablesV2 = !$projectTables && !$sharedTablesV1;
|
||||
$sharedTables = $sharedTablesV1 || $sharedTablesV2;
|
||||
|
||||
if (!$sharedTablesV2) {
|
||||
if ($projectTables) {
|
||||
$adapter = new DatabasePool($pools->get($dsn->getHost()));
|
||||
$dbForProject = new Database($adapter, $cache);
|
||||
$dbForProject->setDatabase(APP_DATABASE);
|
||||
|
||||
if ($sharedTables) {
|
||||
$tenant = null;
|
||||
if ($sharedTablesV1) {
|
||||
$tenant = $project->getSequence();
|
||||
}
|
||||
$dbForProject
|
||||
->setSharedTables(true)
|
||||
->setTenant($tenant)
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$dbForProject
|
||||
->setSharedTables(false)
|
||||
->setTenant(null)
|
||||
->setNamespace('_' . $project->getSequence());
|
||||
}
|
||||
$dbForProject
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setSharedTables(false)
|
||||
->setTenant(null)
|
||||
->setNamespace('_' . $project->getSequence());
|
||||
|
||||
$create = true;
|
||||
|
||||
|
|
@ -244,27 +226,11 @@ class Create extends Action
|
|||
$create = false;
|
||||
}
|
||||
|
||||
if ($create || $projectTables) {
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$audit = new Audit($adapter);
|
||||
$audit->setup();
|
||||
}
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$audit = new Audit($adapter);
|
||||
$audit->setup();
|
||||
|
||||
if (!$create && $sharedTablesV1) {
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
$attributes = $adapter->getAttributeDocuments();
|
||||
$indexes = $adapter->getIndexDocuments();
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom('audit'),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => 'audit',
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
}
|
||||
|
||||
if ($create || $sharedTablesV1) {
|
||||
if ($create) {
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', [])['projects'] ?? [];
|
||||
|
||||
|
|
@ -279,37 +245,7 @@ class Create extends Action
|
|||
try {
|
||||
$dbForProject->createCollection($key, $attributes, $indexes);
|
||||
} catch (Duplicate) {
|
||||
try {
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom($key),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => $key,
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
} catch (Duplicate) {
|
||||
// Metadata already exists from concurrent creation
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// PostgreSQL adapter may throw a non-Duplicate exception when
|
||||
// a table or index already exists during concurrent project
|
||||
// creation in shared mode. Treat as duplicate if metadata
|
||||
// can be created successfully.
|
||||
try {
|
||||
$dbForProject->createDocument(Database::METADATA, new Document([
|
||||
'$id' => ID::custom($key),
|
||||
'$permissions' => [Permission::create(Role::any())],
|
||||
'name' => $key,
|
||||
'attributes' => $attributes,
|
||||
'indexes' => $indexes,
|
||||
'documentSecurity' => true
|
||||
]));
|
||||
} catch (Duplicate) {
|
||||
// Metadata already exists from concurrent creation
|
||||
} catch (\Throwable) {
|
||||
throw $e; // Rethrow original if metadata creation also fails
|
||||
}
|
||||
// Collection already exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Appwrite\Platform\Tasks;
|
|||
use Appwrite\SDK\Language\AgentSkills;
|
||||
use Appwrite\SDK\Language\Android;
|
||||
use Appwrite\SDK\Language\Apple;
|
||||
use Appwrite\SDK\Language\ClaudePlugin;
|
||||
use Appwrite\SDK\Language\CLI;
|
||||
use Appwrite\SDK\Language\CursorPlugin;
|
||||
use Appwrite\SDK\Language\Dart;
|
||||
|
|
@ -451,6 +452,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|||
case 'cursor-plugin':
|
||||
$config = new CursorPlugin();
|
||||
break;
|
||||
case 'claude-plugin':
|
||||
$config = new ClaudePlugin();
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('Language "' . $language['key'] . '" not supported');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -651,11 +651,8 @@ class Deletes extends Action
|
|||
];
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
$sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
|
||||
|
||||
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
|
||||
$sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
|
||||
$sharedTablesV2 = !$projectTables && !$sharedTablesV1;
|
||||
|
||||
$allDatabases = [
|
||||
new Document([
|
||||
|
|
@ -758,23 +755,7 @@ class Deletes extends Action
|
|||
),
|
||||
$databasesToClean
|
||||
));
|
||||
} elseif ($sharedTablesV1) {
|
||||
/**
|
||||
* Temporary disabling deletes for internal collections
|
||||
*/
|
||||
$queries = \array_map(
|
||||
fn ($id) => Query::notEqual('$id', $id),
|
||||
$projectCollectionIds
|
||||
);
|
||||
|
||||
$queries[] = Query::orderAsc();
|
||||
|
||||
$this->deleteByGroup(
|
||||
Database::METADATA,
|
||||
$queries,
|
||||
$dbForProject
|
||||
);
|
||||
} elseif ($sharedTablesV2) {
|
||||
} else {
|
||||
$queries = \array_map(
|
||||
fn ($id) => Query::notEqual('$id', $id),
|
||||
$projectCollectionIds
|
||||
|
|
|
|||
|
|
@ -195,9 +195,25 @@ class Migrations extends Action
|
|||
$migrationOptions = $migration->getAttribute('options');
|
||||
/** @var Database|null $projectDB */
|
||||
$projectDB = null;
|
||||
if ($credentials['projectId']) {
|
||||
$useAppwriteApiSource = false;
|
||||
if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) {
|
||||
throw new \Exception('Source projectId is required for Appwrite migrations');
|
||||
}
|
||||
|
||||
if (! empty($credentials['projectId'])) {
|
||||
$this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']);
|
||||
$projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
|
||||
if ($this->sourceProject->isEmpty()) {
|
||||
throw new \Exception('Source project not found for provided projectId');
|
||||
}
|
||||
|
||||
$sourceRegion = $this->sourceProject->getAttribute('region', 'default');
|
||||
$destinationRegion = $this->project->getAttribute('region', 'default');
|
||||
$useAppwriteApiSource = $source === SourceAppwrite::getName()
|
||||
&& $destination === DestinationAppwrite::getName()
|
||||
&& $sourceRegion !== $destinationRegion;
|
||||
if (! $useAppwriteApiSource) {
|
||||
$projectDB = call_user_func($this->getProjectDB, $this->sourceProject);
|
||||
}
|
||||
}
|
||||
$getDatabasesDB = fn (Document $database): Database =>
|
||||
$this->getDatabasesDBForProject($database);
|
||||
|
|
@ -233,7 +249,7 @@ class Migrations extends Action
|
|||
$credentials['endpoint'],
|
||||
$credentials['apiKey'],
|
||||
$getDatabasesDB,
|
||||
SourceAppwrite::SOURCE_DATABASE,
|
||||
$useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE,
|
||||
$projectDB,
|
||||
$queries
|
||||
),
|
||||
|
|
@ -578,9 +594,10 @@ class Migrations extends Action
|
|||
|
||||
protected function getDatabasesDBForProject(Document $database)
|
||||
{
|
||||
if ($this->sourceProject) {
|
||||
if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) {
|
||||
return ($this->getDatabasesDB)($database, $this->sourceProject);
|
||||
}
|
||||
|
||||
return ($this->getDatabasesDB)($database);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
|
|||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $payloadEntries
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sendUnsubscribeMessage(WebSocketClient $client, array $payloadEntries): array
|
||||
{
|
||||
$client->send(\json_encode([
|
||||
'type' => 'unsubscribe',
|
||||
'data' => $payloadEntries,
|
||||
]));
|
||||
|
||||
return \json_decode($client->receive(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* subscriptionId: update with id from connected, create by omitting id, explicit new id,
|
||||
* duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all.
|
||||
|
|
@ -293,6 +307,282 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
|
|||
$client->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a subscription's queries/channels by reusing its subscriptionId.
|
||||
* Verifies the update takes effect on live event filtering (not just the response echo),
|
||||
* sibling subscriptions are untouched, unknown ids upsert as new, empty queries fall
|
||||
* back to select-all, and a removed id can be recreated by subscribing again.
|
||||
*/
|
||||
public function testUpdateSubscriptionAndEdgeCases(): void
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$userId = $user['$id'] ?? '';
|
||||
$session = $user['session'] ?? '';
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$headers = [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
];
|
||||
|
||||
$queryString = \http_build_query(['project' => $projectId]);
|
||||
$client = new WebSocketClient(
|
||||
'ws://appwrite.test/v1/realtime?' . $queryString,
|
||||
[
|
||||
'headers' => $headers,
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$connected = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('connected', $connected['type'] ?? null);
|
||||
|
||||
$triggerAccountEvent = function () use ($projectId, $session): void {
|
||||
$this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]), ['name' => 'Update Sub Test ' . \uniqid()]);
|
||||
};
|
||||
|
||||
// subA matches current user, subB never matches
|
||||
$created = $this->sendSubscribeMessage($client, [
|
||||
[
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
],
|
||||
[
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', ['no-match-initial'])->toString()],
|
||||
],
|
||||
]);
|
||||
$subA = $created['data']['subscriptions'][0]['subscriptionId'];
|
||||
$subB = $created['data']['subscriptions'][1]['subscriptionId'];
|
||||
$this->assertNotSame($subA, $subB);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertSame([$subA], $event['data']['subscriptions']);
|
||||
|
||||
// Swap: A -> non-matching, B -> matching. Same ids returned, server-side filter swaps.
|
||||
$swap = $this->sendSubscribeMessage($client, [
|
||||
[
|
||||
'subscriptionId' => $subA,
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', ['no-match-swapped'])->toString()],
|
||||
],
|
||||
[
|
||||
'subscriptionId' => $subB,
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
],
|
||||
]);
|
||||
$this->assertSame($subA, $swap['data']['subscriptions'][0]['subscriptionId']);
|
||||
$this->assertSame($subB, $swap['data']['subscriptions'][1]['subscriptionId']);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertSame([$subB], $event['data']['subscriptions']);
|
||||
|
||||
// Sibling isolation: updating only subA must leave subB's matching filter intact.
|
||||
$isolation = $this->sendSubscribeMessage($client, [[
|
||||
'subscriptionId' => $subA,
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
]]);
|
||||
$this->assertSame($subA, $isolation['data']['subscriptions'][0]['subscriptionId']);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
|
||||
|
||||
// Empty queries on update -> select-all; subA still matches every event on the channel.
|
||||
$empty = $this->sendSubscribeMessage($client, [[
|
||||
'subscriptionId' => $subA,
|
||||
'channels' => ['account'],
|
||||
'queries' => [],
|
||||
]]);
|
||||
$this->assertSame($subA, $empty['data']['subscriptions'][0]['subscriptionId']);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
|
||||
|
||||
// Unknown subscriptionId upserts as a new subscription.
|
||||
$ghostId = ID::unique();
|
||||
$ghost = $this->sendSubscribeMessage($client, [[
|
||||
'subscriptionId' => $ghostId,
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
]]);
|
||||
$this->assertSame($ghostId, $ghost['data']['subscriptions'][0]['subscriptionId']);
|
||||
$this->assertNotSame($subA, $ghostId);
|
||||
$this->assertNotSame($subB, $ghostId);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']);
|
||||
|
||||
// Update after unsubscribe: subscribing with the removed id recreates it.
|
||||
$unsub = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
|
||||
$this->assertTrue($unsub['data']['subscriptions'][0]['removed']);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subB, $ghostId], $event['data']['subscriptions']);
|
||||
|
||||
$recreated = $this->sendSubscribeMessage($client, [[
|
||||
'subscriptionId' => $subA,
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
]]);
|
||||
$this->assertSame($subA, $recreated['data']['subscriptions'][0]['subscriptionId']);
|
||||
|
||||
$triggerAccountEvent();
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']);
|
||||
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testUnsubscribeRemovesOnlyMatchingSubscription(): void
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$userId = $user['$id'] ?? '';
|
||||
$session = $user['session'] ?? '';
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$headers = [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
];
|
||||
|
||||
$queryString = \http_build_query(['project' => $projectId]);
|
||||
$client = new WebSocketClient(
|
||||
'ws://appwrite.test/v1/realtime?' . $queryString,
|
||||
[
|
||||
'headers' => $headers,
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$connected = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('connected', $connected['type'] ?? null);
|
||||
|
||||
// Two subscriptions on the `account` channel, both matching the current user
|
||||
$r1 = $this->sendSubscribeMessage($client, [[
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::equal('$id', [$userId])->toString()],
|
||||
]]);
|
||||
$subA = $r1['data']['subscriptions'][0]['subscriptionId'];
|
||||
|
||||
$r2 = $this->sendSubscribeMessage($client, [[
|
||||
'channels' => ['account'],
|
||||
'queries' => [Query::select(['*'])->toString()],
|
||||
]]);
|
||||
$subB = $r2['data']['subscriptions'][0]['subscriptionId'];
|
||||
|
||||
$this->assertNotSame($subA, $subB);
|
||||
|
||||
// Trigger an event -- both subscriptions should match
|
||||
$name = 'Unsubscribe Test ' . \uniqid();
|
||||
$this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]), ['name' => $name]);
|
||||
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']);
|
||||
|
||||
// Unsubscribe subA only
|
||||
$unsubA = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
|
||||
$this->assertEquals('response', $unsubA['type']);
|
||||
$this->assertEquals('unsubscribe', $unsubA['data']['to']);
|
||||
$this->assertTrue($unsubA['data']['success']);
|
||||
$this->assertCount(1, $unsubA['data']['subscriptions']);
|
||||
$this->assertSame($subA, $unsubA['data']['subscriptions'][0]['subscriptionId']);
|
||||
$this->assertTrue($unsubA['data']['subscriptions'][0]['removed']);
|
||||
|
||||
// Trigger another event -- only subB should match now
|
||||
$name = 'Unsubscribe Test ' . \uniqid();
|
||||
$this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]), ['name' => $name]);
|
||||
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertSame([$subB], $event['data']['subscriptions']);
|
||||
|
||||
// Idempotent: unsubscribing subA again reports removed=false
|
||||
$unsubAgain = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]);
|
||||
$this->assertTrue($unsubAgain['data']['success']);
|
||||
$this->assertFalse($unsubAgain['data']['subscriptions'][0]['removed']);
|
||||
|
||||
// Connection is still alive -- ping still works
|
||||
$client->send(\json_encode(['type' => 'ping']));
|
||||
$pong = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('pong', $pong['type']);
|
||||
|
||||
// Invalid payloads are rejected
|
||||
$errNonString = $this->sendUnsubscribeMessage($client, [['subscriptionId' => 123]]);
|
||||
$this->assertEquals('error', $errNonString['type']);
|
||||
$this->assertStringContainsString('subscriptionId', $errNonString['data']['message']);
|
||||
|
||||
$errEmpty = $this->sendUnsubscribeMessage($client, [['subscriptionId' => '']]);
|
||||
$this->assertEquals('error', $errEmpty['type']);
|
||||
|
||||
$errMissing = $this->sendUnsubscribeMessage($client, [['channels' => ['foo']]]);
|
||||
$this->assertEquals('error', $errMissing['type']);
|
||||
|
||||
$errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]);
|
||||
$this->assertEquals('error', $errNonList['type']);
|
||||
|
||||
// A batch with a valid id followed by an invalid one must be rejected atomically:
|
||||
// the valid id must remain subscribed, not be quietly removed before validation fails.
|
||||
$partial = $this->sendUnsubscribeMessage($client, [
|
||||
['subscriptionId' => $subB],
|
||||
['subscriptionId' => 999],
|
||||
]);
|
||||
$this->assertEquals('error', $partial['type']);
|
||||
|
||||
$name = 'Partial Rejection Test ' . \uniqid();
|
||||
$this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]), ['name' => $name]);
|
||||
|
||||
$event = \json_decode($client->receive(), true);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertSame([$subB], $event['data']['subscriptions']);
|
||||
|
||||
// Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order
|
||||
$bulk = $this->sendUnsubscribeMessage($client, [
|
||||
['subscriptionId' => $subB],
|
||||
['subscriptionId' => 'does-not-exist'],
|
||||
]);
|
||||
$this->assertTrue($bulk['data']['success']);
|
||||
$this->assertCount(2, $bulk['data']['subscriptions']);
|
||||
$this->assertSame($subB, $bulk['data']['subscriptions'][0]['subscriptionId']);
|
||||
$this->assertTrue($bulk['data']['subscriptions'][0]['removed']);
|
||||
$this->assertSame('does-not-exist', $bulk['data']['subscriptions'][1]['subscriptionId']);
|
||||
$this->assertFalse($bulk['data']['subscriptions'][1]['removed']);
|
||||
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testInvalidQueryShouldNotSubscribe(): void
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
|
|
|||
|
|
@ -147,6 +147,193 @@ class MessagingTest extends TestCase
|
|||
$this->assertEmpty($realtime->subscriptions);
|
||||
}
|
||||
|
||||
public function testSubscribeUnionsChannelsAndRoles(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::user(ID::custom('123'))->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-b',
|
||||
[Role::users()->toString()],
|
||||
['files'],
|
||||
);
|
||||
|
||||
$connection = $realtime->connections[1];
|
||||
|
||||
$this->assertContains('documents', $connection['channels']);
|
||||
$this->assertContains('files', $connection['channels']);
|
||||
$this->assertContains(Role::user(ID::custom('123'))->toString(), $connection['roles']);
|
||||
$this->assertContains(Role::users()->toString(), $connection['roles']);
|
||||
$this->assertCount(2, $connection['channels']);
|
||||
$this->assertCount(2, $connection['roles']);
|
||||
}
|
||||
|
||||
public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::user(ID::custom('123'))->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-b',
|
||||
[Role::users()->toString()],
|
||||
['files'],
|
||||
);
|
||||
|
||||
$removed = $realtime->unsubscribeSubscription(1, 'sub-a');
|
||||
|
||||
$this->assertTrue($removed);
|
||||
$this->assertArrayHasKey(1, $realtime->connections);
|
||||
|
||||
// sub-a is fully cleaned from the tree
|
||||
$this->assertArrayNotHasKey(
|
||||
Role::user(ID::custom('123'))->toString(),
|
||||
$realtime->subscriptions['1']
|
||||
);
|
||||
|
||||
// sub-b still delivers
|
||||
$event = [
|
||||
'project' => '1',
|
||||
'roles' => [Role::users()->toString()],
|
||||
'data' => [
|
||||
'channels' => ['files'],
|
||||
],
|
||||
];
|
||||
$receivers = array_keys($realtime->getSubscribers($event));
|
||||
$this->assertEquals([1], $receivers);
|
||||
|
||||
// Channels recomputed: sub-a's channel is gone
|
||||
$this->assertSame(['files'], $realtime->connections[1]['channels']);
|
||||
|
||||
// Roles are connection-level auth context — union of both subscribe calls preserved
|
||||
$this->assertContains(Role::user(ID::custom('123'))->toString(), $realtime->connections[1]['roles']);
|
||||
$this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']);
|
||||
}
|
||||
|
||||
public function testUnsubscribeSubscriptionIsIdempotent(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::users()->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$this->assertFalse($realtime->unsubscribeSubscription(1, 'does-not-exist'));
|
||||
$this->assertFalse($realtime->unsubscribeSubscription(99, 'sub-a'));
|
||||
|
||||
// Original sub is untouched
|
||||
$event = [
|
||||
'project' => '1',
|
||||
'roles' => [Role::users()->toString()],
|
||||
'data' => [
|
||||
'channels' => ['documents'],
|
||||
],
|
||||
];
|
||||
$this->assertEquals([1], array_keys($realtime->getSubscribers($event)));
|
||||
}
|
||||
|
||||
public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::users()->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a'));
|
||||
|
||||
$this->assertArrayHasKey(1, $realtime->connections);
|
||||
$this->assertSame([], $realtime->connections[1]['channels']);
|
||||
// Roles preserved so a later resubscribe on the same connection still has auth context
|
||||
$this->assertSame([Role::users()->toString()], $realtime->connections[1]['roles']);
|
||||
$this->assertArrayNotHasKey('1', $realtime->subscriptions);
|
||||
}
|
||||
|
||||
public function testResubscribeAfterUnsubscribingLastSubDelivers(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::users()->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a'));
|
||||
|
||||
// Simulate the message-based subscribe path reading stored roles
|
||||
$storedRoles = $realtime->connections[1]['roles'];
|
||||
$this->assertNotEmpty($storedRoles, 'connection roles must survive per-subscription removal');
|
||||
|
||||
$realtime->subscribe('1', 1, 'sub-b', $storedRoles, ['files']);
|
||||
|
||||
$event = [
|
||||
'project' => '1',
|
||||
'roles' => [Role::users()->toString()],
|
||||
'data' => [
|
||||
'channels' => ['files'],
|
||||
],
|
||||
];
|
||||
$this->assertEquals([1], array_keys($realtime->getSubscribers($event)));
|
||||
}
|
||||
|
||||
public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void
|
||||
{
|
||||
$realtime = new Realtime();
|
||||
|
||||
// Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'',
|
||||
[Role::users()->toString()],
|
||||
[],
|
||||
[],
|
||||
'user-123',
|
||||
);
|
||||
|
||||
// Now a real subscription comes in via the subscribe message type
|
||||
$realtime->subscribe(
|
||||
'1',
|
||||
1,
|
||||
'sub-a',
|
||||
[Role::user(ID::custom('user-123'))->toString()],
|
||||
['documents'],
|
||||
);
|
||||
|
||||
$this->assertSame('user-123', $realtime->connections[1]['userId']);
|
||||
$this->assertContains('documents', $realtime->connections[1]['channels']);
|
||||
$this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']);
|
||||
$this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']);
|
||||
}
|
||||
|
||||
public function testConvertChannelsGuest(): void
|
||||
{
|
||||
$user = new Document([
|
||||
|
|
|
|||
Loading…
Reference in a new issue