diff --git a/.gitignore b/.gitignore index 1d4d5f1ee..d02c62f2c 100755 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ Makefile .vscode tmp *.sql + +/compat/vendor/ diff --git a/Dockerfile b/Dockerfile index 15db406a1..2b7cbf919 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ RUN composer install \ --no-scripts \ --prefer-dist +COPY compat/composer.json compat/composer.lock /usr/local/legacy/ +RUN composer install --working-dir=/usr/local/legacy --ignore-platform-reqs --no-dev --no-plugins --no-scripts --prefer-dist + FROM php:8.5.8-cli-alpine AS compile ENV PHP_REDIS_VERSION="6.3.0" \ @@ -103,6 +106,7 @@ ENV DEBUG=$DEBUG WORKDIR /usr/src/code COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor +COPY --from=composer /usr/local/legacy/vendor /opt/database-legacy/vendor COPY --from=swoole /ext/swoole.so /ext/swoole.so COPY --from=redis /ext/redis.so /ext/redis.so COPY --from=pcov /ext/pcov.so /ext/pcov.so diff --git a/compat/composer.json b/compat/composer.json new file mode 100644 index 000000000..c777c4a7e --- /dev/null +++ b/compat/composer.json @@ -0,0 +1,11 @@ +{ + "name": "utopia-php/database-bridge-compatibility", + "description": "Isolated stable cache-5/database-8 runtime for rolling migration tests", + "require": { + "utopia-php/database": "8.3.15", + "utopia-php/cache": "5.2.0" + }, + "config": { + "allow-plugins": false + } +} diff --git a/compat/composer.lock b/compat/composer.lock new file mode 100644 index 000000000..5ac82a7f7 --- /dev/null +++ b/compat/composer.lock @@ -0,0 +1,2512 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "526b53034e04411168d76b88dbdf86b3", + "packages": [ + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, + { + "name": "composer/semver", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.6.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-29T09:13:34+00:00" + }, + { + "name": "google/protobuf", + "version": "v5.36.2", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "phpunit/phpunit": ">=11.5.50 <12.0.0" + }, + "suggest": { + "ext-bcmath": "Need to support JSON deserialization" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Protobuf\\": "src/Google/Protobuf", + "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "proto library for PHP", + "homepage": "https://developers.google.com/protocol-buffers/", + "keywords": [ + "proto" + ], + "support": { + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2" + }, + "time": "2026-09-17T15:56:42+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "open-telemetry/api", + "version": "1.10.1", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", + "shasum": "" + }, + "require": { + "open-telemetry/context": "^1.4", + "php": "^8.1", + "psr/log": "^1.1|^2.0|^3.0", + "symfony/polyfill-php82": "^1.26" + }, + "conflict": { + "open-telemetry/sdk": "<=1.11" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "files": [ + "Trace/functions.php" + ], + "psr-4": { + "OpenTelemetry\\API\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "API for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "api", + "apm", + "logging", + "opentelemetry", + "otel", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-09-16T01:59:09+00:00" + }, + { + "name": "open-telemetry/context", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/context.git", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/polyfill-php82": "^1.26" + }, + "suggest": { + "ext-ffi": "To allow context switching in Fibers" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "fiber/initialize_fiber_handler.php" + ], + "psr-4": { + "OpenTelemetry\\Context\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Context implementation for OpenTelemetry PHP.", + "keywords": [ + "Context", + "opentelemetry", + "otel" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, + { + "name": "open-telemetry/exporter-otlp", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/exporter-otlp.git", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", + "shasum": "" + }, + "require": { + "open-telemetry/api": "^1.0", + "open-telemetry/gen-otlp-protobuf": "^1.1", + "open-telemetry/sdk": "^1.0", + "php": "^8.1", + "php-http/discovery": "^1.14" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "_register.php" + ], + "psr-4": { + "OpenTelemetry\\Contrib\\Otlp\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "OTLP exporter for OpenTelemetry.", + "keywords": [ + "Metrics", + "exporter", + "gRPC", + "http", + "opentelemetry", + "otel", + "otlp", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-02-05T09:44:52+00:00" + }, + { + "name": "open-telemetry/gen-otlp-protobuf", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", + "shasum": "" + }, + "require": { + "google/protobuf": "^3.22 || ^4.0 || ^5.0", + "php": "^8.0" + }, + "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", + "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", + "keywords": [ + "Metrics", + "apm", + "gRPC", + "logging", + "opentelemetry", + "otel", + "otlp", + "protobuf", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-06-17T12:06:32+00:00" + }, + { + "name": "open-telemetry/sdk", + "version": "1.15.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sdk.git", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7-server": "^1.1", + "open-telemetry/api": "^1.8", + "open-telemetry/context": "^1.4", + "open-telemetry/sem-conv": "^1.38.0", + "php": "^8.1", + "php-http/discovery": "^1.14", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0", + "ramsey/uuid": "^3.0 || ^4.0", + "symfony/polyfill-mbstring": "^1.23", + "symfony/polyfill-php82": "^1.26", + "tbachert/spi": "^1.0.5" + }, + "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", + "ext-mbstring": "To increase performance of string operations", + "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", + "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" + ], + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" + ], + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.14.x-dev" + } + }, + "autoload": { + "files": [ + "Common/Util/functions.php", + "Logs/Exporter/_register.php", + "Metrics/MetricExporter/_register.php", + "Propagation/_register.php", + "Trace/SpanExporter/_register.php", + "Common/Dev/Compatibility/_load.php", + "_autoload.php" + ], + "psr-4": { + "OpenTelemetry\\SDK\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "SDK for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "sdk", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-07-14T13:09:54+00:00" + }, + { + "name": "open-telemetry/sem-conv", + "version": "1.44.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sem-conv.git", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\SemConv\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Semantic conventions for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "semantic conventions", + "semconv", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-09-09T22:06:17+00:00" + }, + { + "name": "opis/closure", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary data.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous classes", + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/4.5.0" + }, + "time": "2026-03-05T13:32:42+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "9dd7ba9dc326b37d7f6e19588de79b5d91b20d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/9dd7ba9dc326b37d7f6e19588de79b5d91b20d66", + "reference": "9dd7ba9dc326b37d7f6e19588de79b5d91b20d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-26T21:52:50+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T09:35:49+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "tbachert/spi", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/Nevay/spi.git", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "composer/semver": "^1.0 || ^2.0 || ^3.0", + "php": "^8.1" + }, + "require-dev": { + "composer/composer": "^2.0", + "infection/infection": "^0.27.9", + "phpunit/phpunit": "^10.5", + "psalm/phar": "^5.18" + }, + "type": "composer-plugin", + "extra": { + "class": "Nevay\\SPI\\Composer\\Plugin", + "branch-alias": { + "dev-main": "1.0.x-dev" + }, + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Nevay\\SPI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Service provider loading facility", + "keywords": [ + "service provider" + ], + "support": { + "issues": "https://github.com/Nevay/spi/issues", + "source": "https://github.com/Nevay/spi/tree/v1.0.5" + }, + "time": "2025-06-29T15:42:06+00:00" + }, + { + "name": "utopia-php/async", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/async.git", + "reference": "c7925a2ad7cf0fceebb3f93200c95a269126f692" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/async/zipball/c7925a2ad7cf0fceebb3f93200c95a269126f692", + "reference": "c7925a2ad7cf0fceebb3f93200c95a269126f692", + "shasum": "" + }, + "require": { + "opis/closure": "4.*", + "php": ">=8.1" + }, + "require-dev": { + "amphp/amp": "3.*", + "amphp/parallel": "2.*", + "amphp/process": "^2.0", + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.5.45", + "react/child-process": "0.*", + "react/event-loop": "1.*", + "swoole/ide-helper": "*" + }, + "suggest": { + "amphp/amp": "Required for Amp promise adapter", + "amphp/parallel": "Required for Amp parallel adapter", + "ext-ev": "Required for ReactPHP event loop (recommended for best performance)", + "ext-parallel": "Required for parallel adapter (requires PHP ZTS build)", + "ext-sockets": "Required for Swoole Process adapter", + "ext-swoole": "Required for Swoole Thread and Process adapters (recommended for best performance)", + "react/child-process": "Required for ReactPHP parallel adapter", + "react/event-loop": "Required for ReactPHP promise and parallel adapters" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Appwrite Team", + "email": "team@appwrite.io" + } + ], + "description": "High-performance concurrent + parallel library with Promise and Parallel execution support for PHP.", + "support": { + "issues": "https://github.com/utopia-php/async/issues", + "source": "https://github.com/utopia-php/async/tree/0.2.0" + }, + "time": "2026-09-29T14:07:22+00:00" + }, + { + "name": "utopia-php/cache", + "version": "5.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/cache.git", + "reference": "05a631796066a7cf96da289df358909fa2190ebd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/05a631796066a7cf96da289df358909fa2190ebd", + "reference": "05a631796066a7cf96da289df358909fa2190ebd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.4", + "utopia-php/circuit-breaker": "^0.4", + "utopia-php/pools": "^2.0", + "utopia-php/telemetry": "^0.4" + }, + "require-dev": { + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-igbinary": "Required for the Igbinary codec.", + "ext-memcached": "Required for the Memcached and Hazelcast adapters.", + "ext-redis": "Required for the Redis, RedisCluster and Sharding adapters.", + "ext-swoole": "Required for the multiplexing Redis adapter (>=6.0)." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A simple cache library to manage application cache storing, loading and purging", + "keywords": [ + "cache", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/cache/issues", + "source": "https://github.com/utopia-php/cache/tree/5.2.0" + }, + "time": "2026-10-19T05:47:09+00:00" + }, + { + "name": "utopia-php/circuit-breaker", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/circuit-breaker.git", + "reference": "51c9b9b0de6754af4da6364da0f3a5d7e940ef2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/51c9b9b0de6754af4da6364da0f3a5d7e940ef2c", + "reference": "51c9b9b0de6754af4da6364da0f3a5d7e940ef2c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "utopia-php/telemetry": "^0.4.6" + }, + "suggest": { + "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", + "ext-protobuf": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", + "ext-redis": "Required when using Utopia\\CircuitBreaker\\Adapter\\Redis with the phpredis extension.", + "ext-swoole": "Required when using Utopia\\CircuitBreaker\\Adapter\\SwooleTable.", + "utopia-php/telemetry": "Required when passing telemetry adapters or running the local telemetry demo." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\CircuitBreaker\\": "src/CircuitBreaker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "Light & simple Circuit Breaker for PHP to prevent cascading failures in distributed systems.", + "keywords": [ + "circuit-breaker", + "fault-tolerance", + "framework", + "php", + "resilience", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/circuit-breaker/issues", + "source": "https://github.com/utopia-php/circuit-breaker/tree/0.4.1" + }, + "time": "2026-09-16T10:12:28+00:00" + }, + { + "name": "utopia-php/console", + "version": "0.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/console.git", + "reference": "d298e43960780e6d76e66de1228c75dc81220e3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/console/zipball/d298e43960780e6d76e66de1228c75dc81220e3e", + "reference": "d298e43960780e6d76e66de1228c75dc81220e3e", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.6", + "swoole/ide-helper": "4.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Console helpers for logging, prompting, and executing commands", + "keywords": [ + "cli", + "console", + "php", + "terminal", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/console/issues", + "source": "https://github.com/utopia-php/console/tree/0.1.1" + }, + "time": "2026-02-10T10:20:29+00:00" + }, + { + "name": "utopia-php/database", + "version": "8.3.15", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/database.git", + "reference": "bc00297bd34c1a1311cebb723ab3810563324908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/database/zipball/bc00297bd34c1a1311cebb723ab3810563324908", + "reference": "bc00297bd34c1a1311cebb723ab3810563324908", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-mongodb": "*", + "ext-pdo": "*", + "ext-redis": "*", + "php": ">=8.5", + "utopia-php/async": "^0.2", + "utopia-php/cache": "^5.2", + "utopia-php/console": "^0.1 || ^0.2", + "utopia-php/mongo": "^1.5.4", + "utopia-php/pools": "^2.0", + "utopia-php/query": "^0.7.2", + "utopia-php/validators": "^1.0 || ^2.0" + }, + "require-dev": { + "brianium/paratest": "^7.20", + "fakerphp/faker": "^1.23", + "laravel/pint": "^1.29", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^12.5", + "swoole/ide-helper": "5.1.3", + "utopia-php/cli": "^0.24.4" + }, + "suggest": { + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", + "ext-redis": "Needed to support Redis Cache Adapter", + "ext-swoole": "Needed to scope lifecycle hook silencing per coroutine and to detect lost connections" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0.x-dev", + "dev-feat-query-lib": "8.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Utopia\\Database\\": "src/Database" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library to manage application persistence using multiple database adapters", + "keywords": [ + "database", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/database/issues", + "source": "https://github.com/utopia-php/database/tree/8.3.15" + }, + "time": "2026-10-23T00:11:16+00:00" + }, + { + "name": "utopia-php/mongo", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/mongo.git", + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", + "shasum": "" + }, + "require": { + "ext-mongodb": "^2.1.0", + "php": ">=8.0", + "ramsey/uuid": "4.9.*" + }, + "require-dev": { + "fakerphp/faker": "1.*", + "laravel/pint": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "9.*", + "swoole/ide-helper": "5.1.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Mongo\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + }, + { + "name": "Wess", + "email": "wess@appwrite.io" + } + ], + "description": "A simple library to manage Mongo database", + "keywords": [ + "database", + "mongo", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/mongo/issues", + "source": "https://github.com/utopia-php/mongo/tree/1.5.4" + }, + "time": "2026-09-09T05:42:37+00:00" + }, + { + "name": "utopia-php/pools", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/pools.git", + "reference": "a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a", + "reference": "a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "utopia-php/telemetry": "^0.4.6" + }, + "require-dev": { + "swoole/ide-helper": "6.*" + }, + "suggest": { + "ext-mongodb": "Needed to support MongoDB database pools", + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite database pools", + "ext-redis": "Needed to support Redis cache pools", + "ext-swoole": "Needed to support Swoole based pool adapter" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Pools\\": "src/Pools" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A simple library to manage connection pools", + "keywords": [ + "framework", + "php", + "pools", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/pools/issues", + "source": "https://github.com/utopia-php/pools/tree/2.1.2" + }, + "time": "2026-09-07T07:02:57+00:00" + }, + { + "name": "utopia-php/query", + "version": "0.7.2", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/query.git", + "reference": "945ef1c9f1c863b0e6e8bbac4c81c1b667dbfc9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/query/zipball/945ef1c9f1c863b0e6e8bbac4c81c1b667dbfc9c", + "reference": "945ef1c9f1c863b0e6e8bbac4c81c1b667dbfc9c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "mongodb/mongodb": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Query\\": "src/" + } + }, + "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.7.2" + }, + "time": "2026-10-16T10:52:31+00:00" + }, + { + "name": "utopia-php/telemetry", + "version": "0.4.12", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/telemetry.git", + "reference": "e628707fe262dc0ca418fbe6f5e421f2db719907" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/e628707fe262dc0ca418fbe6f5e421f2db719907", + "reference": "e628707fe262dc0ca418fbe6f5e421f2db719907", + "shasum": "" + }, + "require": { + "nyholm/psr7": "1.*", + "open-telemetry/exporter-otlp": "1.*", + "open-telemetry/sdk": "1.*", + "php": ">=8.0", + "symfony/http-client": "7.*" + }, + "require-dev": { + "swoole/ide-helper": "6.*" + }, + "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format", + "ext-sockets": "Required for the Swoole transport implementation", + "ext-swoole": "Required for the Swoole transport implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Telemetry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lite & fast telemetry library, with adapters for OpenTelemetry", + "keywords": [ + "framework", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/telemetry/issues", + "source": "https://github.com/utopia-php/telemetry/tree/0.4.12" + }, + "time": "2026-09-28T10:14:23+00:00" + }, + { + "name": "utopia-php/validators", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "4c6e421fb84333eec58d28595e645a77d4ed8ac5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/4c6e421fb84333eec58d28595e645a77d4ed8ac5", + "reference": "4c6e421fb84333eec58d28595e645a77d4ed8ac5", + "shasum": "" + }, + "require": { + "php": ">=8.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/compat.php" + ], + "psr-4": { + "Utopia\\Validator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/2.0.0" + }, + "time": "2026-10-08T14:16:57+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/compat/runtime.php b/compat/runtime.php new file mode 100644 index 000000000..87ba0ed23 --- /dev/null +++ b/compat/runtime.php @@ -0,0 +1,48 @@ +connect(getenv('DATABASE_TEST_CACHE_HOST') ?: 'redis', (int) (getenv('DATABASE_TEST_CACHE_PORT') ?: 6379)); +$cache = new Cache(new RedisCache($redis)); +$database = new Database(new SQLite(new PDO('sqlite:'.$arguments['path'])), $cache); +$database->setDatabase('bridge')->setNamespace($arguments['namespace']); +$database->setQueryCache(new QueryCache($cache)); + +$result = match ($arguments['action']) { + 'init' => (function () use ($database): bool { + $database->create(); + $database->createCollection(Collection::create(id: 'users', attributes: [Attribute::string(key: 'name')], permissions: [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())])); + $database->createDocument('users', new Document(['$id' => 'user', 'name' => 'original'])); + return true; + })(), + 'get' => $database->getDocument('users', $arguments['id'] ?? 'user')->getAttribute('name'), + 'find' => array_map(fn (Document $document) => $document->getAttribute('name'), $database->find('users')), + 'update' => $database->updateDocument('users', 'user', new Document(['name' => $arguments['value']]))->getAttribute('name'), + 'create' => $database->createDocument('users', new Document(['$id' => $arguments['id'], 'name' => $arguments['value']]))->getAttribute('name'), + 'delete' => $database->deleteDocument('users', $arguments['id']), + 'attributes' => array_map(fn (Document $attribute) => $attribute->getId(), $database->getCollection('users')->getAttribute('attributes')), + 'hold' => $database->withTransaction(function () use ($database, $arguments): string { + $database->updateDocument('users', 'user', new Document(['name' => $arguments['value']])); + echo "ready\n"; + fflush(STDOUT); + if (fgets(STDIN) !== "commit\n") { + throw new RuntimeException('Commit signal missing'); + } + return $arguments['value']; + }), +}; +echo json_encode($result, JSON_THROW_ON_ERROR)."\n"; diff --git a/composer.json b/composer.json index c393ab2aa..5aa60a082 100755 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "ext-redis": "*", "utopia-php/validators": "^1.0 || ^2.0", "utopia-php/console": "^0.1 || ^0.2", - "utopia-php/cache": "^6.0", + "utopia-php/cache": "^6.1.1", "utopia-php/pools": "^2.0", "utopia-php/mongo": "^1.5.4", "utopia-php/query": "^0.7.2", diff --git a/composer.lock b/composer.lock index 2243fae73..543465029 100644 --- a/composer.lock +++ b/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": "e95611681420d2fa5709ac90a368e813", + "content-hash": "5d299b8d0325e34aee41d67f7bd57ded", "packages": [ { "name": "brick/math", @@ -2007,16 +2007,16 @@ }, { "name": "utopia-php/cache", - "version": "6.0.0", + "version": "6.1.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "2fef3be8b1aee62b831625a03b221c8e041c7328" + "reference": "8d72a5e91baaa11855cd0ebf6c8934b596f2a93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/2fef3be8b1aee62b831625a03b221c8e041c7328", - "reference": "2fef3be8b1aee62b831625a03b221c8e041c7328", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/8d72a5e91baaa11855cd0ebf6c8934b596f2a93a", + "reference": "8d72a5e91baaa11855cd0ebf6c8934b596f2a93a", "shasum": "" }, "require": { @@ -2061,9 +2061,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/6.0.0" + "source": "https://github.com/utopia-php/cache/tree/6.1.1" }, - "time": "2026-10-30T21:12:57+00:00" + "time": "2026-10-30T23:38:33+00:00" }, { "name": "utopia-php/circuit-breaker", diff --git a/docker-compose.yml b/docker-compose.yml index 10b3c036c..972207a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - ./bin:/usr/src/code/bin - ./src:/usr/src/code/src - ./tests:/usr/src/code/tests + - ./compat:/usr/src/code/compat - ./dev:/usr/src/code/dev - ./phpunit.xml:/usr/src/code/phpunit.xml - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini diff --git a/docs/cache-invalidation-state.md b/docs/cache-invalidation-state.md index c8491ccb9..6e9a09d8f 100644 --- a/docs/cache-invalidation-state.md +++ b/docs/cache-invalidation-state.md @@ -6,6 +6,26 @@ The same opaque versions fence document, definition and query fills. CAS returns This requires cache 6.x; the old generation API and compatibility protocol are removed. Redis, Redis multiplexing and Memory support versioning. Database document/query/withCache caching is bypassed on unsupported adapters; ordinary database operations continue. The old cache-5 compatibility CI tier is removed because it is no longer a supported dependency. -No additional Dragonfly or coordination DSN is needed. Drain and stop legacy API/worker/task/migration writers, clear disposable cache data, deploy matching library versions everywhere and resume. Do not mix protocols. Rollback requires the same drain/clear procedure. +A direct upgrade to 9.0 requires draining legacy writers and clearing disposable cache data. For a rolling migration without a regional stop, use the explicit bridge below instead. No additional Dragonfly or coordination DSN is needed. A successful post-commit invalidation remains necessary. Durable replay of completions lost during a cache outage is separate work. A cache timeout/OOM must never be interpreted as an empty writer record. + +## Rolling migration (database 9.1 / cache 6.1.1) + +Use cache 6.1.1 or later: ordinary legacy registrations must bypass the conditional-fill grace window. Pass the same `Cache\Bridge` instance to Database and its query cache. `Bridge::wrap($cache, $mode)` accepts an empty mode for the ordinary v2 path, or one of three migration phases: + +| Phase | Reads | Mutation registration/completion and purges | +| --- | --- | --- | +| `legacy` | Existing cache-5 keyspace and wire format | Both protocols | +| `dual` | Isolated `v2:` keyspace | Both protocols | +| `v2` | Same `v2:` keyspace | V2 only | + +Roll every cache user through `legacy` before enabling `dual`. Old APIs, enterprise/storage pools, realtime/MQTT, workers, schedules and one-off scripts must have terminated gracefully, including their in-flight SQL transactions. Readiness alone does not prove that barrier. Never enable a v2 reader alongside an unmodified legacy-only writer sharing its cache domain. The wrapper does not discover fleet membership; the deployment operator must verify each barrier. + +During `dual`, either bridge read mode can coexist and rollback to `legacy` remains safe. Once every reader uses v2 and the rollback window closes, roll `v2`. After the first v2-only writer, rolling back to legacy readers is unsafe because their entries are no longer maintained; rollback to `dual` with v2 reads remains possible. Preserve the `v2:` namespace during eventual bridge removal. + +No additional Dragonfly, whole-cache flush or regional shutdown is needed. Use API surge capacity, `maxUnavailable: 0`, readiness and graceful draining; hand workers over using their normal acknowledgement/retry semantics. Warm the new namespace gradually and budget memory for both keyspaces. Watch SQL load, cache evictions, errors, latency and queue age. Cross-region semantic purge delivery must stay available and be tested in both directions. + +Compatibility is deliberately limited to the Redis wire protocol (including multiplexing, pools, sharding and circuit breakers). Legacy fills retain numeric leases only inside the explicit facade; v2 remains opaque CAS with no fallback. No opaque version fields are written into legacy owner hashes. Required failures throw, and both post-write completions are attempted even if one fails. Definitions, negative documents, queries and caller-owned `withCache` regions are invalidated in both keyspaces. Do not mix separate raw and bridge caches in one Database/query-cache pair. + +The compatibility test runtime installs pinned stable database 8.3.15/cache 5.2.0 under `compat/` and runs in a separate process against the same Redis and SQLite database. Locally, run `composer install --working-dir=compat --no-dev` before the unit suite; the CI image installs it automatically. diff --git a/src/Database/Cache/Bridge.php b/src/Database/Cache/Bridge.php new file mode 100644 index 000000000..3a6af4bfc --- /dev/null +++ b/src/Database/Cache/Bridge.php @@ -0,0 +1,210 @@ + dual -> v2 in separate passes. + * All cache users must finish each pass before the next starts. + */ +final class Bridge extends Cache +{ + private readonly Cache $legacy; + + public function __construct(private readonly Cache $cache, private readonly string $mode) + { + if (! \in_array($mode, ['legacy', 'dual', 'v2'], true)) { + throw new InvalidArgumentException('Cache migration mode must be legacy, dual or v2'); + } + parent::__construct($cache->adapter); + $this->setCaseSensitivity($cache->caseSensitive); + $this->legacy = $cache->legacy(); + } + + public static function wrap(Cache $cache, string $mode = ''): Cache + { + return $mode === '' ? $cache : new self($cache, $mode); + } + + #[\Override] + public function supportsVersioning(): bool + { + return $this->cache->supportsVersioning(); + } + + private function reader(): Cache + { + return $this->mode === 'legacy' ? $this->legacy : $this->cache; + } + + private function key(string $key): string + { + return $this->mode === 'legacy' ? $key : 'v2:'.$key; + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->reader()->load($this->key($key), $ttl, $hash); + } + + #[\Override] + public function save(string $key, mixed $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->reader()->save($this->key($key), $data, $hash, $ttl); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + return $this->reader()->touch($this->key($key), $hash); + } + + /** @return string[] */ + #[\Override] + public function list(string $key): array + { + return $this->reader()->list($this->key($key)); + } + + /** @param string[] $fields + * @return array + */ + #[\Override] + public function loadMany(string $key, int $ttl, array $fields = []): array + { + return $this->reader()->loadMany($this->key($key), $ttl, $fields); + } + + /** @param array $data + * @return array|false + */ + #[\Override] + public function saveMany(string $key, array $data, int $ttl = 0): array|false + { + // Database caching does not batch-fill during the legacy pass. + if ($this->mode === 'legacy') { + throw new Unavailable('Legacy migration does not support batch fills'); + } + + return $this->cache->saveMany('v2:'.$key, $data, $ttl); + } + + /** @return array{version: string, value: mixed} */ + #[\Override] + public function loadVersioned(string $key): array + { + return $this->cache->loadVersioned('v2:'.$key); + } + + /** @param array|string $data */ + #[\Override] + public function compareAndSwap(string $key, array|string $data, string $version): string|false + { + return $this->cache->compareAndSwap('v2:'.$key, $data, $version); + } + + #[\Override] + public function getLease(string $key): string + { + return $this->reader()->getLease($this->key($key)); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $version): bool|string|array + { + return $this->reader()->saveWithLease($this->key($key), $data, $hash, $version); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + $this->recordPurge($key, $hash); + $result = false; + $this->both( + function () use ($key, $hash, &$result): void { + $result = $this->legacy->purge($key, $hash) || $result; + }, + function () use ($key, $hash, &$result): void { + $result = $this->cache->purge('v2:'.$key, $hash) || $result; + }, + ); + + return $result; + } + + /** Rotate caller-owned withCache regions in both keyspaces, including when one is cold. */ + public function rotate(string $key): void + { + $this->both( + fn () => $this->rotateIn($this->legacy, $key), + fn () => $this->rotateIn($this->cache, 'v2:'.$key), + ); + } + + private function rotateIn(Cache $cache, string $key): void + { + $cache->purge($key); + if ($cache->save($key, \bin2hex(\random_bytes(16))) === false) { + throw new Unavailable('Failed to rotate cache region'); + } + } + + public function epoch(string $key, int $timeout, ?int $ttl = null): Epoch + { + if ($this->mode !== 'legacy') { + return (new Mutation($this->cache))->read('v2:'.$key, $timeout); + } + $legacy = new Legacy($this->legacy); + + return $ttl === null ? $legacy->document($key, $timeout) : new Epoch($legacy->query($key, $ttl, $timeout)); + } + + public function begin(string $key, string $token, int $timeout): void + { + // Establish both barriers before SQL. Do not undo one after an uncertain failure. + if ($this->mode !== 'v2') { + (new Legacy($this->legacy))->begin($key, $token); + } + (new Mutation($this->cache))->begin('v2:'.$key, $token, $timeout); + } + + public function finish(string $key, string $token, int $timeout): void + { + $this->both( + fn () => (new Legacy($this->legacy))->finish($key, $token, $timeout), + fn () => (new Mutation($this->cache))->finish('v2:'.$key, $token, $timeout), + ); + } + + /** Attempt both completions even when one fails; never disguise failure as a cache miss. */ + private function both(callable $legacy, callable $versioned): void + { + $failure = null; + if ($this->mode !== 'v2') { + try { + $legacy(); + } catch (Throwable $error) { + $failure = $error; + } + } + try { + $versioned(); + } catch (Throwable $error) { + $failure ??= $error; + } + if ($failure !== null) { + throw $failure; + } + } + + #[\Override] + public function flush(): bool + { + return $this->cache->flush(); + } +} diff --git a/src/Database/Cache/Legacy.php b/src/Database/Cache/Legacy.php new file mode 100644 index 000000000..fa6212fd2 --- /dev/null +++ b/src/Database/Cache/Legacy.php @@ -0,0 +1,257 @@ +cache->load($key.'#epoch', self::PERMANENT); + if (\is_string($value) && $value !== '') { + $separator = \strrpos($value, '@'); + $marker = $separator === false ? $value : \substr($value, 0, $separator); + $stamp = $separator === false ? '' : \substr($value, $separator + 1); + if (\str_starts_with($value, 'blocked:')) { + $blocked = \ctype_digit($stamp) ? (int) $stamp : 0; + + return $blocked + $timeout > $now + ? new Epoch(blockedAt: $blocked) + : new Epoch('lapsed:'.\substr($value, 8).'@'.$this->cache->getLease($key.'#finished')); + } + $started = $this->cache->getLease($key.'#started'); + if (($separator !== false && $started === $stamp) || $started === $this->cache->getLease($key.'#finished')) { + return new Epoch($marker); + } + } + $started = $this->cache->getLease($key.'#started'); + if ($started !== $this->cache->getLease($key.'#finished')) { + if ($this->cache->save($key.'#epoch', 'blocked:'.$now.'.'.\bin2hex(\random_bytes(16)).'@'.$now) === false) { + throw new Unavailable('Failed to restore legacy tombstone'); + } + + return new Epoch(blockedAt: $now); + } + return new Epoch($this->publish($key, $started)); + } + + public function begin(string $key, string $token): void + { + if ($this->cache->save($key.'#owners', $token, $token) === false) { + throw new Unavailable("Failed to register legacy cache owner for '{$key}'"); + } + + if ($this->cache->save(($key.'#epoch'), self::BLOCKED_PREFIX.$token.self::SEPARATOR.\time()) === false) { + throw new Unavailable("Failed to block legacy cache epoch for '{$key}'"); + } + + $this->cache->purge(($key.'#started')); + } + + /** + * Replace this mutation's shared tombstone with a fresh usable epoch once no + * other mutation of the collection is in progress. + * + * @param int $writerTimeout Seconds after which another writer's registration is treated as abandoned + */ + public function finish(string $key, string $token, int $writerTimeout): void + { + $owners = $key.'#owners'; + $owner = $this->cache->load($owners, self::PERMANENT, $token); + if ($owner !== false && $owner !== null && $owner !== $token) { + throw new RuntimeException("Invalid legacy cache owner for '{$key}'"); + } + $owned = $owner === $token; + if ($owned && ! $this->cache->purge($owners, $token)) { + $owner = $this->cache->load($owners, self::PERMANENT, $token); + if ($owner !== false && $owner !== null) { + throw new Unavailable("Failed to release legacy cache owner for '{$key}'"); + } + $owned = false; + } + + $epochKey = ($key.'#epoch'); + $startedKey = ($key.'#started'); + $finishedKey = ($key.'#finished'); + $started = $this->cache->getLease($startedKey); + $finished = $this->cache->getLease($finishedKey); + $current = $this->cache->load($epochKey, self::PERMANENT); + $ours = $this->isTombstoneOf($current, $token); + + if ($started === $finished) { + if ($ours || ! $this->isTombstone($current)) { + $this->publish($key, $finished); + } + + return; + } + + if (! $owned && ! $ours) { + if ($this->isActive($current)) { + $this->publish($key, $started); + } + + return; + } + + $this->cache->purge($finishedKey); + $nextStarted = $this->cache->getLease($startedKey); + $nextFinished = $this->cache->getLease($finishedKey); + + if ($nextStarted === $nextFinished) { + $this->publish($key, $nextFinished); + + return; + } + + if ($nextFinished === $finished && $this->isTombstoneOf($this->cache->load($epochKey, self::PERMANENT), $token)) { + throw new Unavailable("Failed to finish legacy cache invalidation for '{$key}'"); + } + + if ($this->releaseAbandonedOwners($owners, $writerTimeout)) { + $this->publish($key, $nextStarted); + } + } + + public function query(string $key, int $ttl, int $writerTimeout): ?string + { + $value = $this->cache->load(($key.'#epoch'), self::PERMANENT); + + if ($value === false || $value === null) { + return $this->cache->getLease($key) === self::NEVER_STARTED + ? self::INITIAL_EPOCH + : null; + } + + if (! \is_string($value) || $value === '') { + return null; + } + + $separator = \strrpos($value, self::SEPARATOR); + if ($separator === false) { + return null; + } + $marker = \substr($value, 0, $separator); + $stamp = \substr($value, $separator + 1); + + if (\str_starts_with($value, self::BLOCKED_PREFIX)) { + return $this->getLapsedEpoch($key, $ttl, $value, (int) $stamp, $writerTimeout); + } + + if (! \str_starts_with($value, self::ACTIVE_PREFIX)) { + return null; + } + + $started = $this->cache->getLease(($key.'#started')); + if ($started !== $stamp && $started !== $this->cache->getLease(($key.'#finished'))) { + return null; + } + + return $marker; + } + + /** + * A tombstone lapses after the region TTL once no write is counted in flight, and after the writer + * timeout while one is, since its writer may have died before activating. The lapsed epoch belongs + * to this tombstone and the finished generation, so nothing filled before the block, or before a + * later activation, is served under it. + */ + private function getLapsedEpoch(string $key, int $ttl, string $tombstone, int $stamp, int $writerTimeout): ?string + { + $now = \time(); + if ($stamp + \min($ttl, $writerTimeout) > $now) { + return null; + } + + $started = $this->cache->getLease(($key.'#started')); + $finished = $this->cache->getLease(($key.'#finished')); + if ($stamp + ($started === $finished ? $ttl : $writerTimeout) > $now) { + return null; + } + + return self::LAPSED_PREFIX.\substr($tombstone, \strlen(self::BLOCKED_PREFIX)).self::SEPARATOR.$finished; + } + + /** + * Release every other writer still registered when all of them are older than the writer timeout. + * A token without a creation time counts as live. + */ + private function releaseAbandonedOwners(string $owners, int $writerTimeout): bool + { + $now = \time(); + $abandoned = []; + foreach ($this->cache->list($owners) as $token) { + $created = $this->getTokenTime($token); + if ($created === null || $created + $writerTimeout > $now) { + return false; + } + + $abandoned[] = $token; + } + + foreach ($abandoned as $token) { + $this->cache->purge($owners, $token); + } + + return true; + } + + private function getTokenTime(string $token): ?int + { + $separator = \strpos($token, self::TOKEN_SEPARATOR); + $time = $separator === false ? '' : \substr($token, 0, $separator); + + return \ctype_digit($time) ? (int) $time : null; + } + + private function isActive(mixed $value): bool + { + return \is_string($value) && \str_starts_with($value, self::ACTIVE_PREFIX); + } + + private function isTombstone(mixed $value): bool + { + return \is_string($value) && \str_starts_with($value, self::BLOCKED_PREFIX); + } + + private function isTombstoneOf(mixed $value, string $token): bool + { + return \is_string($value) && \str_starts_with($value, self::BLOCKED_PREFIX.$token.self::SEPARATOR); + } + + private function publish(string $key, string $finished): string + { + $epoch = self::ACTIVE_PREFIX.\bin2hex(\random_bytes(16)); + + if ($this->cache->save(($key.'#epoch'), $epoch.self::SEPARATOR.$finished) === false) { + throw new Unavailable("Failed to activate legacy cache for '{$key}'"); + } + + return $epoch; + } + + + + + + + +} diff --git a/src/Database/Cache/Mutation.php b/src/Database/Cache/Mutation.php index 883e2f156..fdb15a9db 100644 --- a/src/Database/Cache/Mutation.php +++ b/src/Database/Cache/Mutation.php @@ -16,19 +16,29 @@ public function __construct(private Cache $cache) { } - public function read(string $key, int $timeout): Epoch + public function read(string $key, int $timeout, ?int $ttl = null): Epoch { - return $this->change($key, $timeout); + return $this->cache instanceof Bridge + ? $this->cache->epoch($key, $timeout, $ttl) + : $this->change($key, $timeout); } public function begin(string $key, string $token, int $timeout): void { - $this->change($key, $timeout, $token, true); + if ($this->cache instanceof Bridge) { + $this->cache->begin($key, $token, $timeout); + } else { + $this->change($key, $timeout, $token, true); + } } public function finish(string $key, string $token, int $timeout): void { - $this->change($key, $timeout, $token); + if ($this->cache instanceof Bridge) { + $this->cache->finish($key, $token, $timeout); + } else { + $this->change($key, $timeout, $token); + } } private function change(string $key, int $timeout, ?string $token = null, bool $begin = false): Epoch diff --git a/src/Database/Cache/Query.php b/src/Database/Cache/Query.php index 2546f5feb..705c5d924 100644 --- a/src/Database/Cache/Query.php +++ b/src/Database/Cache/Query.php @@ -76,7 +76,7 @@ public function getEntry(Scope $scope, string $collection, array $queries, strin } $key = $this->getCollectionKey($scope, $collection); - $epoch = (new Mutation($this->cache))->read($key, $scope->writerTimeout)->value; + $epoch = (new Mutation($this->cache))->read($key, $scope->writerTimeout, $this->getRegion($collection)->ttl)->value; if ($epoch === null) { return null; } @@ -86,9 +86,10 @@ public function getEntry(Scope $scope, string $collection, array $queries, strin 'context' => $context, ])); - $slot = (string) ((int) \hexdec(\substr($field, 0, 8)) % $this->slots); + $slot = (int) \hexdec(\substr($field, 0, 8)) % $this->slots; - return new Entry($key, $collection, $field, $epoch, $slot); + // Cache transports alias field '0' to the default field; conditional fills must use the same field. + return new Entry($key, $collection, $field, $epoch, $slot === 0 ? '' : (string) $slot); } /** @@ -184,7 +185,7 @@ public function invalidateCollection(Scope $scope, string $collection): void */ public function createToken(): string { - return \bin2hex(\random_bytes(16)); + return \time().'.'.\bin2hex(\random_bytes(16)); } public function blockCollection(string $key, string $token): void diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 504a65525..ab3e13590 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -3674,7 +3674,7 @@ private function loadDocumentCacheState(string $collectionKey): Epoch */ private function createDocumentCacheToken(): string { - return \bin2hex(\random_bytes(16)); + return \time().'.'.\bin2hex(\random_bytes(16)); } private function advanceDocumentCacheEpoch(string $collectionKey, string $definitionKey): bool @@ -3746,8 +3746,11 @@ protected function activateDocumentInvalidation(array $tokens): void /** Completion always changes the identity, even if eviction lost this registration. */ private function activateDocumentCacheEpoch(string $collectionKey, string $token, string $definitionKey): void { - $this->withRetries(fn () => (new Mutation($this->cache))->finish($collectionKey, $token, $this->cacheWriterTimeout)); - $this->withRetries(fn () => $this->purgeCachedDefinition($definitionKey)); + try { + $this->withRetries(fn () => (new Mutation($this->cache))->finish($collectionKey, $token, $this->cacheWriterTimeout)); + } finally { + $this->withRetries(fn () => $this->purgeCachedDefinition($definitionKey)); + } } /** @@ -3863,9 +3866,14 @@ public function purgeCachedQueries(string $collection, ?string $namespace = null $epochKey = $this->getQueryCacheKey($collection, $namespace).'#epoch'; try { - $existing = $this->cache->load($epochKey, self::TTL); - $rotated = ($existing === false || $existing === null || $this->cache->purge($epochKey)) - && $this->cache->save($epochKey, \bin2hex(\random_bytes(16))) !== false; + if ($this->cache instanceof \Utopia\Database\Cache\Bridge) { + $this->cache->rotate($epochKey); + $rotated = true; + } else { + $existing = $this->cache->load($epochKey, self::TTL); + $rotated = ($existing === false || $existing === null || $this->cache->purge($epochKey)) + && $this->cache->save($epochKey, \bin2hex(\random_bytes(16))) !== false; + } } catch (Exception $error) { Console::warning('Warning: Failed to purge the cached queries: '.$error->getMessage()); $rotated = false; diff --git a/tests/unit/Cache/BridgeTest.php b/tests/unit/Cache/BridgeTest.php new file mode 100644 index 000000000..659fcfea5 --- /dev/null +++ b/tests/unit/Cache/BridgeTest.php @@ -0,0 +1,311 @@ +redis = new Redis(); + $this->redis->connect(\getenv('DATABASE_TEST_CACHE_HOST') ?: 'redis', (int) (\getenv('DATABASE_TEST_CACHE_PORT') ?: 6379)); + $this->cache = new Cache(new RedisCache($this->redis)); + $this->namespace = 'bridge_'.\bin2hex(\random_bytes(8)); + $path = \tempnam(\sys_get_temp_dir(), 'cache-bridge-'); + $this->assertNotFalse($path); + $this->path = $path; + $this->assertTrue($this->old('init')); + } + + #[\Override] + protected function tearDown(): void + { + if (isset($this->redis, $this->namespace)) { + $cursor = null; + do { + $keys = $this->redis->scan($cursor, '*'.$this->namespace.'*', 1000); + if ($keys !== false && $keys !== []) { + $this->redis->del($keys); + } + } while ($cursor !== 0); + $this->redis->close(); + } + if (isset($this->path)) { + \unlink($this->path); + } + } + + public function testFirstPassKeepsOldAndBridgeReadersWarmAndInvalidatesBothDirections(): void + { + $database = $this->database('legacy'); + $this->assertSame('original', $this->old('get')); + $this->assertSame(['original'], $this->old('find')); + $this->assertSame('original', $database->getDocument('users', 'user')->getAttribute('name')); + $database->setProfiling(true); + $database->getDocument('users', 'user'); + $profiler = $database->getProfiler(); + $this->assertNotNull($profiler); + $profiler->reset(); + $database->getDocument('users', 'user'); + $this->assertSame(0, $profiler->getQueryCount(), 'The legacy pass keeps the hot path cached'); + $database->updateDocument('users', 'user', new Document(['name' => 'bridge'])); + $this->assertSame('bridge', $this->old('get')); + $this->assertSame(['bridge'], $this->old('find')); + $this->old('update', ['value' => 'old']); + $this->assertSame('old', $database->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('old', $database->find('users')[0]->getAttribute('name')); + $key = $database->getCacheBaseKeys('users')[0]; + $this->assertFalse($this->redis->hExists(\strtolower($key.'#owners'), '__utopia_version__'), 'The bridge must not pollute v1 owner hashes with v2 internal fields'); + $this->assertSame([], $this->cache->legacy()->list($key.'#owners')); + } + + public function testSecondAndThirdPassesInvalidateDocumentsQueriesAndNegativeEntries(): void + { + $legacy = $this->database('legacy'); + $dual = $this->database('dual'); + $this->assertSame('original', $legacy->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $dual->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $dual->find('users')[0]->getAttribute('name')); + $this->assertTrue($legacy->getDocument('users', 'missing')->isEmpty()); + $this->assertTrue($dual->getDocument('users', 'missing')->isEmpty()); + $legacy->createDocument('users', new Document(['$id' => 'missing', 'name' => 'created'])); + $this->assertSame('created', $dual->getDocument('users', 'missing')->getAttribute('name')); + $dual->updateDocument('users', 'user', new Document(['name' => 'dual'])); + $this->assertSame('dual', $legacy->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('dual', $this->old('get'), 'Rollback readers remain valid during the dual-write window'); + $this->assertContains('dual', \array_map(fn (Document $document) => $document->getAttribute('name'), $legacy->find('users'))); + $v2 = $this->database('v2'); + $v2->updateDocument('users', 'user', new Document(['name' => 'final'])); + $this->assertSame('final', $dual->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('final', $v2->getDocument('users', 'user')->getAttribute('name')); + $this->assertContains('final', \array_map(fn (Document $document) => $document->getAttribute('name'), $dual->find('users'))); + } + + public function testRapidWritesWithCloudsGraceWindowKeepBothProtocolsUsable(): void + { + $cache = new Cache((new RedisCache($this->redis))->setLeaseGraceWindow(1000)); + foreach (['legacy', 'dual', 'v2'] as $mode) { + $database = $this->database($mode, $cache); + $database->updateDocuments('users', new Document(['name' => $mode.'-first'])); + $database->updateDocuments('users', new Document(['name' => $mode.'-second'])); + $this->assertSame($mode.'-second', $database->getDocument('users', 'user')->getAttribute('name')); + if ($mode !== 'v2') { + $this->assertSame($mode.'-second', $this->old('get'), 'Legacy readers remain valid during dual invalidation'); + } + } + } + + public function testSlotZeroRemainsCacheableAcrossEveryPhase(): void + { + foreach (['', 'legacy', 'dual', 'v2'] as $mode) { + $cache = Bridge::wrap($this->cache, $mode); + $query = new QueryCache($cache, slots: 1); + $scope = new Scope(namespace: $this->namespace.$mode); + $entry = $query->getEntry($scope, 'users', []); + $this->assertNotNull($entry); + $this->assertTrue($query->set($entry, [new Document(['$id' => 'warm'])], $query->getLease($entry))); + $next = $query->getEntry($scope, 'users', []); + $this->assertNotNull($next); + $documents = $query->get($next); + $this->assertNotNull($documents, 'The zero slot must load the same field that the lease filled'); + $this->assertSame('warm', $documents[0]->getId()); + } + } + + public function testSchemaInvalidationRetiresDefinitionsInBothKeyspaces(): void + { + $legacy = $this->database('legacy'); + $dual = $this->database('dual'); + $legacy->getCollection('users'); + $dual->getCollection('users'); + $dual->createAttribute('users', Attribute::string(key: 'extra')); + $attributes = function (Database $database): array { + $items = $database->getCollection('users')->getAttribute('attributes'); + $this->assertIsArray($items); + + return \array_map(function (mixed $attribute): string { + $this->assertInstanceOf(Document::class, $attribute); + + return $attribute->getId(); + }, $items); + }; + $this->assertContains('extra', $attributes($legacy)); + $this->assertContains('extra', $attributes($dual)); + $oldAttributes = $this->old('attributes'); + $this->assertIsArray($oldAttributes); + $this->assertContains('extra', $oldAttributes); + } + + public function testAnOldTransactionFinishesBeforeTheReadSwitch(): void + { + [$process, $pipes] = $this->startOld('hold', ['value' => 'committed']); + try { + $this->assertSame("ready\n", \fgets($pipes[1])); + $legacy = $this->database('legacy'); + $this->assertSame('original', $legacy->getDocument('users', 'user')->getAttribute('name')); + \fwrite($pipes[0], "commit\n"); + \fclose($pipes[0]); + $this->assertSame('committed', \json_decode((string) \stream_get_contents($pipes[1]), true, flags: JSON_THROW_ON_ERROR)); + $this->assertSame('', \stream_get_contents($pipes[2])); + $this->assertSame(0, \proc_close($process)); + $process = null; + $this->assertSame('committed', $legacy->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('committed', $this->database('dual')->getDocument('users', 'user')->getAttribute('name')); + } finally { + if (\is_resource($process)) { + \proc_terminate($process); + \proc_close($process); + } + } + } + + public function testEvictionDuringDualWritesRetiresFillsAndPreservesNewerWriters(): void + { + $bridge = new Bridge($this->cache, 'dual'); + $mutation = new Mutation($bridge); + $key = $this->namespace; + $first = \time().'.first'; + $second = \time().'.second'; + $mutation->begin($key, $first, 3600); + $this->redis->del('v2:'.$key.'#state'); + $cold = $mutation->read($key, 3600)->value; + $this->assertNotNull($cold); + $mutation->begin($key, $second, 3600); + $mutation->finish($key, $first, 3600); + $this->assertNull($mutation->read($key, 3600)->value); + $mutation->finish($key, $second, 3600); + $this->assertNotSame($cold, $mutation->read($key, 3600)->value); + } + + public function testWithCacheInvalidationAndPurgeRecordingsCoverBothReaders(): void + { + $legacy = $this->database('legacy'); + $dual = $this->database('dual'); + $key = $legacy->getQueryCacheKey('users'); + $before = \bin2hex(\random_bytes(8)); + $after = \bin2hex(\random_bytes(8)); + $this->assertSame($before, $legacy->withCache($key, fn () => $before)); + $this->assertSame($before, $dual->withCache($key, fn () => $before)); + $this->assertTrue($dual->purgeCachedQueries('users')); + $this->assertSame($after, $legacy->withCache($key, fn () => $after)); + $this->assertSame($after, $dual->withCache($key, fn () => $after)); + $purges = $dual->getCache()->recordPurges(fn () => $dual->getCache()->purge('MiXeD')); + $this->assertSame(['mixed' => ['' => true]], $purges, 'Recorded keys must stay logical so replay does not add the namespace twice'); + } + + public function testRollbackRetiresFillsInBothKeyspaces(): void + { + $legacy = $this->database('legacy'); + $dual = $this->database('dual'); + $legacy->getDocument('users', 'user'); + $dual->getDocument('users', 'user'); + try { + $dual->withTransaction(function () use ($dual): void { + $dual->updateDocument('users', 'user', new Document(['name' => 'rolled-back'])); + throw new \RuntimeException('rollback'); + }); + } catch (\RuntimeException $error) { + $this->assertSame('rollback', $error->getMessage()); + } + $this->assertSame('original', $legacy->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $dual->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $this->old('get')); + } + + public function testFailedLegacyCompletionStillFinishesV2AndReportsFailure(): void + { + $adapter = new FailingLegacyRedis($this->redis); + $bridge = new Bridge(new Cache($adapter), 'dual'); + $mutation = new Mutation($bridge); + $key = $this->namespace; + $token = \time().'.completion'; + $mutation->begin($key, $token, 3600); + $this->assertNull($mutation->read($key, 3600)->value); + $adapter->failCompletion = true; + try { + $mutation->finish($key, $token, 3600); + $this->fail('A partial completion must be reported'); + } catch (Unavailable $error) { + $this->assertSame('Legacy completion unavailable', $error->getMessage()); + } + $this->assertNotNull($mutation->read($key, 3600)->value, 'V2 completion must run even when legacy completion fails'); + $legacy = $this->cache->load($key.'#epoch', PHP_INT_MAX); + $this->assertIsString($legacy); + $this->assertStringStartsWith('blocked:', $legacy); + } + + public function testOpenBreakerAbortsRequiredCoordinationBeforeSQL(): void + { + $breaker = new Breaker(timeout: 3600); + $cache = new Cache(new CircuitBreaker(new RedisCache($this->redis), $breaker)); + $database = $this->database('dual', $cache); + $breaker->trip(); + try { + $database->updateDocument('users', 'user', new Document(['name' => 'must-not-commit'])); + $this->fail('An open breaker must not permit an unfenced SQL write'); + } catch (\Throwable $error) { + $this->assertInstanceOf(Unavailable::class, $error); + $this->assertSame('original', $this->old('get')); + } + } + + private function database(string $mode, ?Cache $cache = null): Database + { + $bridge = new Bridge($cache ?? $this->cache, $mode); + $database = new Database(new SQLite(new PDO('sqlite:'.$this->path)), $bridge); + $database->setDatabase('bridge')->setNamespace($this->namespace)->setQueryCache(new QueryCache($bridge)); + + return $database; + } + + /** @param array $arguments + * @return array{resource, array} + */ + private function startOld(string $action, array $arguments = []): array + { + $process = \proc_open([PHP_BINARY, __DIR__.'/../../../compat/runtime.php', \json_encode($arguments + ['action' => $action, 'path' => $this->path, 'namespace' => $this->namespace], JSON_THROW_ON_ERROR)], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes); + $this->assertIsResource($process); + foreach ($pipes as $pipe) { + \stream_set_timeout($pipe, 15); + } + + return [$process, $pipes]; + } + + /** @param array $arguments */ + private function old(string $action, array $arguments = []): mixed + { + [$process, $pipes] = $this->startOld($action, $arguments); + \fclose($pipes[0]); + $output = \stream_get_contents($pipes[1]); + $error = \stream_get_contents($pipes[2]); + \fclose($pipes[1]); + \fclose($pipes[2]); + $this->assertSame(0, \proc_close($process), (string) $error.' '.(string) $output); + + return \json_decode((string) $output, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/unit/Cache/FailingLegacyRedis.php b/tests/unit/Cache/FailingLegacyRedis.php new file mode 100644 index 000000000..c288ce56f --- /dev/null +++ b/tests/unit/Cache/FailingLegacyRedis.php @@ -0,0 +1,21 @@ +failCompletion && \str_ends_with($key, '#owners')) { + throw new Unavailable('Legacy completion unavailable'); + } + + return parent::purgeLegacy($key, $hash); + } +}