diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..147e920 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,83 @@ +name: "πŸ› Bug Report" +description: "Submit a bug report to help us improve" +title: "πŸ› Bug Report: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out our bug report form πŸ™ + - type: textarea + id: steps-to-reproduce + validations: + required: true + attributes: + label: "πŸ‘Ÿ Reproduction steps" + description: "How do you trigger this bug? Please walk us through it step by step." + placeholder: "When I ..." + - type: textarea + id: expected-behavior + validations: + required: true + attributes: + label: "πŸ‘ Expected behavior" + description: "What did you think would happen?" + placeholder: "It should ..." + - type: textarea + id: actual-behavior + validations: + required: true + attributes: + label: "πŸ‘Ž Actual Behavior" + description: "What did actually happen? Add screenshots, if applicable." + placeholder: "It actually ..." + - type: dropdown + id: utopia-version + attributes: + label: "🎲 Utopia Abuse version" + description: "What version of Utopia Abuse are you running?" + options: + - Version 0.6.x + - Version 0.5.x + - Version 0.4.x + - Version 0.3.x + - Version 0.2.x + - Version 0.1.x + - Different version (specify in environment) + validations: + required: true + - type: dropdown + id: operating-system + attributes: + label: "πŸ’» Operating system" + description: "What OS is your server / device running on?" + options: + - Linux + - MacOS + - Windows + - Something else + validations: + required: true + - type: textarea + id: enviromnemt + validations: + required: false + attributes: + label: "🧱 Your Environment" + description: "Is your environment customized in any way?" + placeholder: "I use Cloudflare for ..." + - type: checkboxes + id: no-duplicate-issues + attributes: + label: "πŸ‘€ Have you spent some time to check if this issue has been raised before?" + description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" + options: + - label: "I checked and didn't find similar issue" + required: true + - type: checkboxes + id: read-code-of-conduct + attributes: + label: "🏒 Have you read the Code of Conduct?" + options: + - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml new file mode 100644 index 0000000..b6189b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yaml @@ -0,0 +1,32 @@ +name: "πŸ“š Documentation" +description: "Report an issue related to documentation" +title: "πŸ“š Documentation: " +labels: [documentation] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to make our documentation better πŸ™ + - type: textarea + id: issue-description + validations: + required: true + attributes: + label: "πŸ’­ Description" + description: "A clear and concise description of what the issue is." + placeholder: "Documentation should not ..." + - type: checkboxes + id: no-duplicate-issues + attributes: + label: "πŸ‘€ Have you spent some time to check if this issue has been raised before?" + description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" + options: + - label: "I checked and didn't find similar issue" + required: true + - type: checkboxes + id: read-code-of-conduct + attributes: + label: "🏒 Have you read the Code of Conduct?" + options: + - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 0000000..630af44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,40 @@ +name: πŸš€ Feature +description: "Submit a proposal for a new feature" +title: "πŸš€ Feature: " +labels: [feature] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out our feature request form πŸ™ + - type: textarea + id: feature-description + validations: + required: true + attributes: + label: "πŸ”– Feature description" + description: "A clear and concise description of what the feature is." + placeholder: "You should add ..." + - type: textarea + id: pitch + validations: + required: true + attributes: + label: "🎀 Pitch" + description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable." + placeholder: "In my use-case, ..." + - type: checkboxes + id: no-duplicate-issues + attributes: + label: "πŸ‘€ Have you spent some time to check if this issue has been raised before?" + description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" + options: + - label: "I checked and didn't find similar issue" + required: true + - type: checkboxes + id: read-code-of-conduct + attributes: + label: "🏒 Have you read the Code of Conduct?" + options: + - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" + required: true diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml new file mode 100644 index 0000000..2f898d4 --- /dev/null +++ b/.github/workflows/analysis.yml @@ -0,0 +1,25 @@ +name: "Static analysis" + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + # Run against the test image rather than a bare composer container: the + # Redis stream calls only resolve where ext-redis is actually installed. + - name: Build + run: | + docker compose build tests + docker compose up -d tests + + - name: Run PHPStan + run: docker compose exec -T tests composer check diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..e98d6e4 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,25 @@ +name: "Linter" + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + # The project's own image, as the analysis job uses: pint needs a PHP + # new enough to parse 8.5 syntax, which `composer:latest` does not pin. + - name: Build + run: | + docker compose build tests + docker compose up -d tests + + - name: Run Linter + run: docker compose exec -T tests composer lint diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9951936 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: "Tests" + +on: + pull_request: + push: + branches: [main] + +jobs: + tests: + name: "PHP ${{ matrix.php-versions }} Β· ${{ matrix.suite }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Add a PHP version here and nowhere else β€” the image is built from one + # parameterized Dockerfile. + php-versions: ['8.5'] + # One job per adapter: each runs the same behaviour scenarios + # (tests/Feed/*/Base.php), so a green suite means that adapter honours + # the whole contract. See phpunit.xml for what each suite contains. + suite: [unit, memory, cache, http, redis, pool] + + env: + PHP_VERSION: ${{ matrix.php-versions }} + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Build + run: | + docker compose build + docker compose up -d + + - name: Run ${{ matrix.suite }} tests + run: docker compose exec -T tests vendor/bin/phpunit --testsuite ${{ matrix.suite }} + + coverage: + name: Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Build + run: | + docker compose build + docker compose up -d + + # Every suite, so the Redis and pooled adapters count too. No threshold + # yet β€” the number is there to be read. + - name: Report coverage + run: docker compose exec -T tests composer coverage:all diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06d7f80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/.idea/ +coverage.xml +.phpunit.cache/ +.phpunit.result.cache +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e6d7031 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +# Changelog + +## 0.1.0 β€” Initial release + +Pull-based HTTP event feeds ([http-feeds.org](https://www.http-feeds.org/)) for +PHP. A producer writes events to an ordered log; each consumer polls for what +happened after the last event it handled, and keeps that position itself. + +Requires PHP 8.5. `ext-redis` is a suggestion, needed only by the Redis and +pooled adapters. + +### Producing + +- `Producer` β€” `produce(type, data, subject)` for the common case, and + `publish(CloudEvent)` for a prepared event. Both return the event's id, which + is also its position in the feed. The producer stamps its own `source`, the + store assigns the `id`, and a missing `time` becomes now; everything else is + published as prepared. +- Events are [utopia-php/cloudevents](https://github.com/utopia-php/cloudevents) + `CloudEvent` objects. This library defines no event type of its own, so a feed + event is accepted anywhere a `CloudEvent` is, and `datacontenttype`, + `dataschema` and extension attributes survive an append and a read. + +### Serving + +- `Server` β€” the feed endpoint. `serve(array $query)` is the whole HTTP request + in one call: it reads `lastEventId`, `limit` and `timeout` from the raw query + parameters, coerces and clamps them, and rejects a `lastEventId` that is + neither a position nor the tip sentinel with `Exception\Invalid`. `read()`, + `poll()` and `tip()` are there for a route that wants the pieces. +- `Batch` β€” one read of a feed. Counts and iterates as its events, and carries + the limit it was actually built with, so `cacheControl()` cannot be handed a + number the read did not use: a full batch is settled history and cacheable + forever, anything shorter is `no-store`. `toArray()` is the wire encoding and + `lastId()` the position a stateless relay tracks. + +### Consuming + +- `Consumer` β€” the pull loop, at-least-once, with a durable position. + `consume(callable)` handles a batch and commits after the last event that + succeeded; a failing handler blocks the events behind it by design. +- Built over a local `Readable` for a feed the same service produces, or + straight over a [utopia-php/client](https://github.com/utopia-php/client) + whose base URI points at the feed endpoint, plus the feed's name. +- `position()`, `reset()` and `seek(eventId)` move the position by hand. + `seek()` is the escape hatch for a poison event: seek to the failing event's + own id to step past it. Both are safe to call from inside a handler. +- `Consumer::START_TIP` starts a consumer with no stored position at the tip of + the feed instead of at the oldest retained event. It rides a protocol + extension β€” the `lastEventId` value `$`, which the producer resolves to its + newest event as the request arrives β€” so skipping the backlog costs no extra + round trip and still delivers what lands mid-poll. + +### Stores and cursors + +- `Store\Redis`, `Store\Pool` β€” a capped Redis stream, directly or over a + [pooled](https://github.com/utopia-php/pools) connection. The pooled store + borrows per read, so a held long poll never ties up a connection. +- `Store\Cache` β€” the feed on a [Utopia cache](https://github.com/utopia-php/cache), + for a service that already carries one. The whole feed lives under one key, + so an append rewrites it (last-writer-wins β€” run one producing process) and + retention is also the cost of producing an event, which is why its default + `maxSize` is 1 000 rather than the 100 000 the Redis store keeps. The newest + id is kept under a second, tiny key so a caught-up long poll does not load + the feed to learn nothing. +- `Store\Memory` β€” tests and single-process development. `Store\None` β€” no + backend configured; throws on use, so a misconfigured service fails loudly + instead of dropping events. +- `Cursor\Cache`, `Cursor\Redis`, `Cursor\Pool`, `Cursor\Memory`, `Cursor\None` + β€” where a consumer's position lives, keyed by feed and consumer name. The + stored form is deliberately plain (`feed::cursor:` holding + the id as a string), so positions carry across upgrades and an operator can + answer "where is this consumer?" from a shell. A run's save is conditional + on the position it started from (read-compare-write), so an instance that + fell behind cannot undo newer progress, a seek, or a reset β€” it adopts the + newer position instead. +- Retention (`maxSize`) and the long-poll read interval (`pollInterval`, in + milliseconds, default 500) are constructor options on every store. + +### The wire + +- A batch is the plain JSON array of CloudEvents the spec defines β€” no + envelope. An empty array means the consumer is caught up. The media type is + `Readable::MEDIA_TYPE`, `application/cloudevents-batch+json`, aliased as + `Batch::MEDIA_TYPE` for the serving side and `Remote::MEDIA_TYPE` for the + consuming one. +- `Readable` is the contract both sides share β€” `read`, `poll`, `tip`, + `getName`, plus `TIP`, `MAX_BATCH` (1000) and `MAX_TIMEOUT` (30s). + `Appendable` is the contract of a store that owns its events; + `Remote` deliberately does not implement it. +- `Remote` β€” another service's feed over HTTP, a `Readable` in its own right + rather than a store. Decoding is strict about `id`, because that is the + consumer's position, and tolerant of everything else, so a consumer older + than the producer keeps working: unknown attributes ride along as + extensions, and an entry that cannot be read ends the batch early rather + than discarding the usable events before it β€” unless it is the first, where + there is no progress to keep and the read fails loudly instead. +- `Id` β€” feed positions (`{ms}-{seq}`) and the arithmetic for paging past one. + `Key` shapes the backend keys, escaping names so a feed and a cursor cannot + collide in one keyspace. + +### Errors + +Everything this library raises extends `Utopia\Feed\Exception`: +`Exception\Invalid` for something the caller handed over, `Exception\Transport` +for a backend or network failure, `Exception\Unsupported` for something a +backend cannot do. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2dec654 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity, expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at team@appwrite.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..29eb794 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# Contributing + +We would ❀️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. + +## How to Start? + +If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you! + +## Code of Conduct + +Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](/CODE_OF_CONDUCT.md). + +## Submit a Pull Request πŸš€ + +Branch naming convention is as following + +`TYPE-ISSUE_ID-DESCRIPTION` + +example: + +``` +doc-548-submit-a-pull-request-section-to-contribution-guide +``` + +When `TYPE` can be: + +- **feat** - is a new feature +- **doc** - documentation only changes +- **cicd** - changes related to CI/CD system +- **fix** - a bug fix +- **refactor** - code change that neither fixes a bug nor adds a feature + +**All PRs must include a commit message with the changes description!** + +For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: + +1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date. + +``` +$ git pull +``` + +2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
+ +``` +$ git checkout -b [name_of_your_new_branch] +``` + +3. Work - commit - repeat ( be sure to be in your branch ) + +4. Push changes to GitHub + +``` +$ git push origin [name_of_your_new_branch] +``` + +5. Submit your changes for review + If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. +6. Start a Pull Request + Now submit the pull request and click on `Create pull request`. +7. Get a code review approval/reject +8. After approval, merge your PR +9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). + +## Introducing New Features + +We would πŸ’– you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement πŸ™. + +For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. + +This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. + +This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). + +## Other Ways to Help + +Pull requests are great, but there are many other areas where you can help Utopia-php. + +### Blogging & Speaking + +Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow. + +### Presenting at Meetups + +Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! + +### Sending Feedbacks & Reporting Bugs + +Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA). + +### Submitting New Ideas + +If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. + +### Improving Documentation + +Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. + +### Helping Someone + +Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c394fe9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +ARG PHP_VERSION=8.5 + +FROM composer:2.7 AS vendor + +WORKDIR /src/ + +COPY composer.lock composer.json /src/ + +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist + +FROM appwrite/utopia-base:php-${PHP_VERSION}-2.1.0 AS final + +LABEL maintainer="team@appwrite.io" + +WORKDIR /code + +# pcov rather than Xdebug: line coverage only, at a fraction of the cost. +# Loaded but switched off, so only `composer coverage` pays for it. +RUN apk add --no-cache --virtual .pcov-build-deps $PHPIZE_DEPS \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && echo 'pcov.enabled=0' >> /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini \ + && apk del .pcov-build-deps + +COPY --from=vendor /src/vendor /code/vendor + +COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer + +COPY ./composer.json /code/composer.json +COPY ./composer.lock /code/composer.lock +COPY ./phpunit.xml /code/phpunit.xml +COPY ./phpstan.neon /code/phpstan.neon +COPY ./pint.json /code/pint.json +COPY ./src /code/src +COPY ./tests /code/tests + +CMD [ "tail", "-f", "/dev/null" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4230d14 --- /dev/null +++ b/README.md @@ -0,0 +1,448 @@ +# Utopia Feed + +[![Build Status](https://github.com/utopia-php/feed/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/feed/actions) +[![Discord](https://img.shields.io/discord/564160730845151244)](https://appwrite.io/discord) + +Utopia Feed moves events between services with **pull-based HTTP event feeds** +([http-feeds.org](https://www.http-feeds.org/)). A producer writes events to +an ordered log; each consumer polls *"what happened since the last event I +saw?"* and keeps its own position, so the producer stores nothing per consumer +and delivery is **at-least-once**. A consumer that was down catches up on its +next poll. + +The whole library in three classes: a `Producer` produces events into a +`Store`, a `Server` serves that store over HTTP, and a `Consumer` consumes the +feed through an HTTP client, keeping its place in a `Cursor`. + +## Which classes are mine? + +| Server (owns the feed) | Client (consumes it) | +| --- | --- | +| `Store` β€” where events live | `Consumer` β€” the pull loop | +| `Producer` β€” writes events | `Cursor` β€” where the position is kept | +| `Server` β€” serves the store | | + +## Install + +```bash +composer require utopia-php/feed +``` + +## Serve a feed + +The server side is three objects over one store β€” where the events live, here +a capped Redis stream: + +```php +use Utopia\Feed\Batch; +use Utopia\Feed\Producer; +use Utopia\Feed\Server; +use Utopia\Feed\Store; + +$store = new Store\Redis($redis, 'edge'); + +// Wherever things happen: +$producer = new Producer($store, source: 'urn:appwrite:cloud:fra'); + +$producer->produce( + type: 'io.appwrite.edge.invalidate-rule', + data: ['tags' => ['domain' => 'example.com']], + subject: 'example.com', +); + +// The whole feed route: +$server = new Server($store); + +// GET /v1/feeds/:feedId +$batch = $server->serve($request->getParams()); + +$response + ->addHeader('Content-Type', Batch::MEDIA_TYPE) // application/cloudevents-batch+json + ->addHeader('Cache-Control', $batch->cacheControl(public: true)) + ->json($batch->toArray()); +``` + +On the wire, the response is a plain JSON array of +[CloudEvents](https://github.com/utopia-php/cloudevents) β€” no envelope. An +empty array means the consumer is caught up: + +```json +[ + { + "specversion": "1.0", + "type": "io.appwrite.edge.invalidate-rule", + "source": "urn:appwrite:cloud:fra", + "id": "1717689471234-0", + "subject": "example.com", + "time": "2026-07-31T09:15:02.123Z", + "data": { "tags": { "domain": "example.com" } } + }, + { + "specversion": "1.0", + "type": "io.appwrite.edge.invalidate", + "source": "urn:appwrite:cloud:fra", + "id": "1717689471234-1", + "time": "2026-07-31T09:15:02.348Z", + "data": { "tags": { "project": "p1" } } + } +] +``` + +`serve()` reads `lastEventId`, `limit` and `timeout` from the raw query +parameters, coerces and clamps them (at most 1000 events per batch, long polls +held at most 30s), and throws `Exception\Invalid` on a malformed `lastEventId` +β€” catch it to answer 400. `produce()` returns the event's id, which is its +position in the feed. Subclass `Producer` to give callers a typed vocabulary +instead of raw type strings. + +`publish()` takes a prepared `CloudEvent` for anything `produce()` cannot +express β€” a `dataschema`, a `traceparent`, a non-JSON `datacontenttype`. Three +attributes are the producer's rather than the caller's and are replaced +whatever the event arrived with: `source` becomes the producer's own, `id` is +assigned by the store (it is also the event's position, so only the store can +order it), and a missing `time` is stamped as now. That matters most when +relaying: an event received from another feed is republished as *this* +service's event, so keep the original origin in an extension attribute if +consumers need it. + +The `Batch` that `serve()` (and `Server::read()`/`poll()`) returns counts and +iterates as its events; `cacheControl()` marks a full batch as immutable +history and everything shorter `no-store`, using the limit the batch was +actually built with, so the header is always honest. + +## Consume a feed + +The client side is a `Consumer` pulling over an HTTP client, with its position +in a `Cursor`. The feed's endpoint is set on the client β€” `withBaseUri()` β€” +and the consumer names the feed it reads. With a `timeout`, each poll is one +held request that returns the moment an event lands (long polling β€” the +producer does the waiting): + +```php +use Utopia\Client; +use Utopia\Client\Adapter\Curl\Client as Curl; +use Utopia\CloudEvents\CloudEvent; +use Utopia\Feed\Consumer; +use Utopia\Feed\Cursor; +use Utopia\Feed\Exception\Transport; + +$client = (new Client(new Curl())) + ->withBaseUri('https://cloud.example.com/v1/feeds') + ->withHeaders(['x-appwrite-jwt' => $token]) + ->withConnectionReuse(); + +$consumer = new Consumer($client, new Cursor\Cache($cache), name: 'cache-invalidator', feed: 'edge', timeout: 20_000); + +while (true) { + try { + $consumer->consume(function (CloudEvent $event) use ($router) { + $router->invalidate($event->data['tags'] ?? []); + }); + } catch (Transport $error) { + // Feed or cursor store unreachable. The position did not move, so + // the next poll is already the retry β€” just don't spin. + \usleep(1_000_000); + } +} +``` + +Events **are** `Utopia\CloudEvents\CloudEvent` objects β€” `data` round-trips +any JSON value, and extension attributes like `traceparent` survive the trip. +Leave the client's `Retry` decorator off: a failed read leaves the position +where it was. + +A consumer inside the producing service reads its own feed the same way β€” +`Consumer` also accepts a local store in place of the client, which already +names its feed: + +```php +$consumer = new Consumer($store, new Cursor\Redis($redis), name: 'audit-log'); +``` + +### Starting at the tip + +A consumer with no stored position starts at the oldest retained event. A +consumer that must not act on the backlog β€” a notifier announcing events as +they happen β€” opts into starting at the tip: + +```php +$consumer = new Consumer($client, $cursor, name: 'notifier', feed: 'edge', timeout: 20_000, start: Consumer::START_TIP); +``` + +A stored position always wins; `Consumer::START_TIP` applies only on the first run or +after `reset()` (which then means "forget everything, resume from now"). Give +a tip consumer a `timeout`: the producer anchors "now" as each poll arrives, +so new events land inside the held request rather than in the gap between +polls. Against a producer that predates the tip extension, the first poll +fails with a 4xx `Transport` error rather than silently replaying the backlog. + +### Moving the position by hand + +- `reset()` β€” forget the position; the next run starts from the oldest + retained event (or the tip, for a `Consumer::START_TIP` consumer). +- `seek($eventId)` β€” treat `$eventId` as the last event handled; the next run + starts strictly *after* it. Persisted immediately; a store failure surfaces + as `Transport`. The id need not still exist in the feed. + +What counts as a usable id is the feed's to say. On a local store β€” which mints +`{ms}-{seq}` positions and pages by decoding them β€” anything else is rejected +as `Invalid`. On a feed read over HTTP the producer is the authority, so any +non-empty id is accepted: http-feeds endpoints commonly use UUIDs, and refusing +one would take the escape hatch below away from precisely the consumers that +have no way around it. Both refuse the tip sentinel `$`, which stands for a +start rather than a position. + +Either is safe to call from inside a handler: a run that finishes after the +move keeps its own progress to itself rather than saving over the newer +decision. The same holds across processes β€” a run's save lands only if the +position is still where that run started, so a move made through another +instance stands, and the instance whose save was refused adopts it. + +`seek()` is the escape hatch for a poison event. A handler that keeps failing +blocks the feed by design, so stepping past it is a decision made in code: +catch the failure, log the event's id, and once you have decided the event +must be skipped, seek to *its own id*: + +```php +try { + $consumer->consume($handler); +} catch (\Throwable $error) { + Console::error("[feed] blocked: {$error->getMessage()}"); + + // After investigating β€” this event cannot and should not be handled: + $consumer->seek($poisonEventId); +} +``` + +## The rules a handler lives by + +**Safe to run twice on the same event.** Three things re-deliver, and none can +be arranged away: + +1. A handler succeeds and the position then fails to save. +2. A run interrupted partway resumes from the last event that succeeded. +3. A consumer whose position was lost restarts from the oldest retained event. + +Every one re-delivers; none skips. An idempotent handler absorbs a duplicate, +whereas an event stepped over is gone. + +**Reject by throwing.** The run stops there, the position stays before the +failed event, and the next run retries it. Everything handled earlier in the +run stays handled. A handler that keeps failing blocks everything behind it β€” +intentionally: a feed is ordered, and stepping over a failure would apply +later events on top of state that was never updated. + +**No position means the oldest retained event, never the tip** (unless the +consumer opted into `Consumer::START_TIP`), so a consumer deployed after the producer +drains the backlog instead of dropping it. + +**One process per consumer name.** Two processes sharing a name share one +position, so the feed is split between them rather than delivered to both. +The position itself holds: a save is conditional on where its run started, so +a process that fell behind cannot undo the other's progress, a `seek()`, or a +`reset()` β€” it adopts the newer position and continues. The check is +read-compare-write rather than atomic, so a race that lands inside one round +trip can still replay a batch: duplicates, which handlers absorb by the first +rule, never a gap. Give every consumer its own name. + +## Reference + +### Stores + +Every store is `Readable` and `Appendable` β€” it owns its events and assigns +their ids. All take `maxSize` (retention, ~100,000 entries by default) and +`pollInterval` (how often a held poll re-reads, 500 ms by default β€” shorter +lowers long-poll latency, raises backend reads). Neither may be below 1 β€” a +store that retains nothing is a misconfiguration, so the constructor throws +`Exception\Invalid`. + +| Store | Use for | +| --- | --- | +| `Store\Redis` | Producing a feed on a Redis stream | +| `Store\Pool` | The same, over a [pooled](https://github.com/utopia-php/pools) connection β€” borrows per read, so a held poll never ties up a connection | +| `Store\Cache` | A feed on a [Utopia cache](https://github.com/utopia-php/cache) β€” for a service that already carries a cache and does not want another backend. One key per feed, rewritten per append (last-writer-wins β€” run one producing process); expires `ttl` after the last append, 30 days by default | +| `Store\Memory` | Tests and single-process development | +| `Store\None` | No backend configured β€” throws on use, so a misconfigured service fails loudly instead of dropping events | + +`Store\Cache` keeps the whole feed under one key, which is what makes it cheap +to adopt and what bounds how far it scales: an append is a read-modify-write of +the entire retained feed, so retention is also the cost of producing one event. +Its default `maxSize` is therefore 1 000 rather than the 100 000 the Redis store +keeps, where trimming happens server-side and reads are ranged. Raising it is a +fine choice for a low-rate feed β€” just a deliberate one. + +Long polls do not pay that cost: the newest id is kept under a second, tiny key +(`feed::tip`), so a caught-up consumer waiting out a 30-second poll checks +that marker each tick instead of loading the feed. The marker is written before +the feed and is only ever used to skip a read, never to answer one, so a missing +or stale marker costs a wasted read rather than a missed event. + +### Cursors + +A cursor is keyed by feed and consumer name, so one store serves every feed a +service consumes. The store may be lossy β€” a lost position costs a replay, not +a gap. + +The stored form is deliberately plain: the key is `feed::cursor:` +and the value is the event id as a string. That is the format consumers have +always written, so positions carry across an upgrade instead of replaying the +retained feed, and `GET feed:edge:cursor:notifier` answers "where is this +consumer?" from a shell. + +A consumer's save is conditional β€” it lands only if the stored position is +still the one its run started from ([`Cursor::advance()`](src/Feed/Cursor.php)), +so a stale instance concedes to a newer save, seek, or reset instead of +undoing it. The check is read-compare-write on every adapter, deliberately: +it needs nothing more than `load()` and `save()`, works on ids with no order, +and narrows a lost update to a single round trip, which at-least-once +delivery absorbs. + +| Cursor | Use for | +| --- | --- | +| `Cursor\Cache` | A consumer with a [Utopia cache](https://github.com/utopia-php/cache) β€” the usual choice for a remote feed. Holds a position for 30 days (`Cursor\Cache::TTL`) unless saved again | +| `Cursor\Redis` | A consumer inside the producer, with no store of its own | +| `Cursor\Pool` | The same, over a pooled connection | +| `Cursor\Memory` | Tests, or deliberate replay-on-restart | +| `Cursor\None` | Remembers nothing β€” every restart replays | + +### Protocol parameters + +The endpoint speaks [http-feeds.org](https://www.http-feeds.org/): a GET +returning a JSON array of CloudEvents, media type +`application/cloudevents-batch+json` (a feed answering `application/json` is +read fine β€” the body shape is what matters). + +| Parameter | Origin | Meaning | +| --- | --- | --- | +| `lastEventId` | spec | The consumer's position: return events strictly after it. Omitted = oldest retained | +| `timeout` | spec | Long poll: hold the request up to this many ms before answering `[]`. Clamped to 30s | +| `limit` | extension | Cap the batch size. Clamped to 1000, which is also the default | +| `lastEventId=$` | extension | The tip: the producer resolves `$` to its newest event as the request arrives. Never collides with a real id | + +A spec-compliant third-party client simply never sends the extensions. + +### Exceptions + +All extend `Utopia\Feed\Exception`. + +| Exception | Thrown when | +| --- | --- | +| `Exception\Invalid` | Input is wrong: a malformed event id or `lastEventId`, an empty feed/consumer name, a payload that cannot be JSON-encoded, a response that is not a feed batch. Answer 400 when it surfaces from `serve()` | +| `Exception\Transport` | The backend or network failed: Redis errors, HTTP failures (the status code is on the exception), a cursor store that is down | +| `Exception\Unsupported` | The operation cannot happen here: any use of `Store\None`, or `tip()` on a remote feed (the producer resolves the tip) | + +## The fine print + +**Rolling out a feed** is a staged change: release the producer (events +accumulate, nothing reads them), then the consumers (each drains the backlog), +and only then remove the old push path. In between, a consumer polling a +producer that does not serve the feed yet gets a 404 β€” normal, not a fault: + +```php +try { + $consumer->consume($handler); +} catch (Transport $error) { + if ($error->getCode() !== 404) { + throw $error; + } + + // Producer does not serve the feed yet; retry quietly until it does. +} +``` + +**Retention is bounded.** Stores trim to about `maxSize` entries (Redis trims +approximately). The oldest retained entry is where a consumer with no position +starts; a consumer that fell behind the trim horizon gets what is left β€” no +error, no detectable gap. Feeds therefore suit events that describe a state to +converge on (a cache tag to drop, a record to refresh) rather than ones whose +effect depends on seeing every single one. + +**Decoding is strict about `id`, lenient about the rest.** The id is the +consumer's position, so an entry without one ends the batch there: everything +before it is handled, and the broken entry heads the next batch, where it +stops the feed loudly (only a broken *first* entry throws `Invalid`). A +producer that adds attributes or moves the spec version forward does not stop +a consumer that predates it β€” the spec's optional `method` attribute included. + +**Why a poll loop instead of `XREAD BLOCK`?** A blocking read holds the +connection for the whole wait, which is exactly what `Store\Pool`'s +borrow-per-read strategy exists to avoid. Tune the trade-off with +`pollInterval`. + +**For integrators** building a transport of their own: the shared contract β€” +the tip sentinel and the batch/timeout limits β€” lives on `Utopia\Feed\Readable`; +`Utopia\Feed\Batch` carries the serving side (encoding, media type, caching +rule) and `Utopia\Feed\Remote` is the client-side `Readable` the consumer +builds over its client. Services never need any of them directly. + +## Tests + +The suite is organized around behaviour, not classes: each component has one +abstract scenario suite β€” `tests/Feed/Producer/Base.php`, +`tests/Feed/Server/Base.php`, `tests/Feed/Consumer/Base.php` β€” and every +adapter extends it, so a passing adapter suite means that adapter honours the +whole contract. Producer and Server run per store (`memory`, `cache`, `redis`, +`pool`); Consumer runs per cursor plus once through the real HTTP wire code +(`http`). What remains in `tests/Feed/Unit` are the cases only a fake can +provoke: a cursor store that is down, a body that is not a batch, a backend +that was never configured. + +The service-free suites need no Redis, but `utopia-php/cache` declares +`ext-redis` and `ext-memcached` and `utopia-php/telemetry` declares +`ext-protobuf`, none of which those suites touch. Install past exactly those +rather than with a blanket `--ignore-platform-reqs`, which would also skip the +PHP version check this library actually depends on: + +```bash +composer install \ + --ignore-platform-req=ext-redis \ + --ignore-platform-req=ext-memcached \ + --ignore-platform-req=ext-protobuf +composer test # unit + memory + cache + http +``` + +The `redis` and `pool` suites run the same scenarios against a real Redis, and +static analysis needs `ext-redis`, so both run in the container: + +```bash +docker compose up -d +docker compose exec tests composer test:redis +docker compose exec tests composer test:pool +docker compose exec tests composer check +``` + +CI runs every suite as its own job, so a failing adapter is visible by name. + +Coverage is measured over `src/` with [pcov](https://github.com/krakjoe/pcov), +which the test image carries loaded but switched off so ordinary runs are +unaffected: + +```bash +docker compose exec tests composer coverage # service-free suites, ~8s +docker compose exec tests composer coverage:all # every suite, needs Redis +``` + +CI prints the full report on every run. There is no threshold: the number is +there to be read, and a badly chosen gate is worse than none. + +To test another PHP version, build with `PHP_VERSION=8.6 docker compose build`, +and add it to the `php-versions` matrix in `.github/workflows/tests.yml`. + +## System requirements + +Utopia Feed requires PHP 8.5 or later. This library is maintained by the +[Appwrite team](https://appwrite.io) and, although part of the +[Utopia Framework](https://github.com/utopia-php/framework), it is dependency +light and works standalone with any PHP project. + +`ext-redis` is a suggestion, not a requirement: it is needed by +`Store\Redis`, `Store\Pool`, `Cursor\Redis` and `Cursor\Pool`, and a service +using the memory or cache adapters, or consuming a feed over HTTP, never loads +those classes. Note that `utopia-php/cache` requires `ext-redis` and +`ext-memcached` itself, so a machine without them still needs +`--ignore-platform-req` to install β€” that constraint comes from the cache +package rather than from this one. + +## Copyright and license + +The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..642a00c --- /dev/null +++ b/composer.json @@ -0,0 +1,68 @@ +{ + "name": "utopia-php/feed", + "description": "A simple library for publishing and consuming pull-based HTTP event feeds", + "type": "library", + "keywords": [ + "php", + "framework", + "upf", + "utopia", + "feed", + "http-feeds", + "cloudevents", + "events", + "streams" + ], + "license": "MIT", + "minimum-stability": "stable", + "scripts": { + "test": "vendor/bin/phpunit --testsuite unit,memory,cache,http", + "test:unit": "vendor/bin/phpunit --testsuite unit", + "test:memory": "vendor/bin/phpunit --testsuite memory", + "test:cache": "vendor/bin/phpunit --testsuite cache", + "test:http": "vendor/bin/phpunit --testsuite http", + "test:redis": "vendor/bin/phpunit --testsuite redis", + "test:pool": "vendor/bin/phpunit --testsuite pool", + "coverage": "php -d pcov.enabled=1 -d pcov.directory=src vendor/bin/phpunit --testsuite unit,memory,cache,http --coverage-text --coverage-clover coverage.xml", + "coverage:all": "php -d pcov.enabled=1 -d pcov.directory=src vendor/bin/phpunit --testsuite unit,memory,cache,http,redis,pool --coverage-text --coverage-clover coverage.xml", + "check": "vendor/bin/phpstan analyse --memory-limit=1G", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, + "autoload": { + "psr-4": { + "Utopia\\Feed\\": "src/Feed" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Feed" + } + }, + "require": { + "php": ">=8.5", + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-message": "^2.0", + "utopia-php/cache": "3.*", + "utopia-php/client": "^0.2", + "utopia-php/pools": "1.*", + "utopia-php/psr7": "^0.2", + "utopia-php/cloudevents": "0.1.*" + }, + "suggest": { + "ext-redis": "Required by the Store\\Redis, Store\\Pool, Cursor\\Redis and Cursor\\Pool adapters. The other adapters, and consuming a feed over HTTP, work without it" + }, + "require-dev": { + "laravel/pint": "^1.21", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.0", + "swoole/ide-helper": "^6.0" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d83cd93 --- /dev/null +++ b/composer.lock @@ -0,0 +1,4397 @@ +{ + "_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": "43c96f9feee2bb8d8dc20f8ac83a2db8", + "packages": [ + { + "name": "brick/math", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "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/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "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": "Semver 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.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "google/protobuf", + "version": "v5.35.1", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "phpunit/phpunit": ">=11.5.0 <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.35.1" + }, + "time": "2026-06-11T21:19:23+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.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", + "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-07-06T12:28:04+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.38.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sem-conv.git", + "reference": "e613bc640a407def4991b8a936a9b27edd9a3240" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", + "reference": "e613bc640a407def4991b8a936a9b27edd9a3240", + "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-01-21T04:14:03+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.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "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.3" + }, + "time": "2026-06-18T03:57:49+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.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/817bb83ef06717f67ab72a3f03e3b63fbe138de1", + "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1", + "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.15" + }, + "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-29T07:12:33+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "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.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/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "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.38.2" + }, + "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-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "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.38.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-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "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.41.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-07-01T12:47:55+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "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.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-16T09:55:08+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/cache", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/cache.git", + "reference": "5c292d4df156f8b008f29fa6b033834b906bf328" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/5c292d4df156f8b008f29fa6b033834b906bf328", + "reference": "5c292d4df156f8b008f29fa6b033834b906bf328", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-memcached": "*", + "ext-redis": "*", + "php": ">=8.3", + "utopia-php/circuit-breaker": "0.3.*", + "utopia-php/pools": "1.*", + "utopia-php/telemetry": "*" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^9.3", + "swoole/ide-helper": "^6.0", + "vimeo/psalm": "4.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Cache\\": "src/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "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/3.4.0" + }, + "time": "2026-07-01T15:44:35+00:00" + }, + { + "name": "utopia-php/circuit-breaker", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/circuit-breaker.git", + "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", + "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0", + "utopia-php/telemetry": "^0.4" + }, + "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.3.1" + }, + "time": "2026-05-29T12:12:23+00:00" + }, + { + "name": "utopia-php/client", + "version": "0.2.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/client.git", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/client/zipball/377dc1f79441ac1584f25dba57904ee1cf0f626b", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b", + "shasum": "" + }, + "require": { + "php": ">=8.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "utopia-php/pools": "^1.0", + "utopia-php/psr7": "^0.2", + "utopia-php/span": "^1.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-curl": "Required to use the cURL HTTP client adapter.", + "ext-simplexml": "Required to decode XML responses with Response::xml().", + "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", + "keywords": [ + "client", + "curl", + "http", + "php", + "psr-18", + "swoole", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/client/issues", + "source": "https://github.com/utopia-php/client/tree/0.2.3" + }, + "time": "2026-07-13T15:29:09+00:00" + }, + { + "name": "utopia-php/cloudevents", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/cloudevents.git", + "reference": "a1cd71180be54b73e7a16469a900dd85d993882d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/cloudevents/zipball/a1cd71180be54b73e7a16469a900dd85d993882d", + "reference": "a1cd71180be54b73e7a16469a900dd85d993882d", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "laravel/pint": "1.25.*", + "phpstan/phpstan": "2.0.*", + "phpunit/phpunit": "12.4.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\CloudEvents\\": "src/CloudEvents" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + } + ], + "description": "Lite & fast micro PHP CloudEvents implementation that is **easy to use**.", + "keywords": [ + "cloudevents", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/cloudevents/issues", + "source": "https://github.com/utopia-php/cloudevents/tree/0.1.0" + }, + "time": "2026-07-31T10:57:20+00:00" + }, + { + "name": "utopia-php/pools", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/pools.git", + "reference": "2c2ee1570a0b16b630671bfa72da5bc9ccf3ac21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/2c2ee1570a0b16b630671bfa72da5bc9ccf3ac21", + "reference": "2c2ee1570a0b16b630671bfa72da5bc9ccf3ac21", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "utopia-php/telemetry": "^0.4" + }, + "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/1.1.1" + }, + "time": "2026-07-27T15:27:26+00:00" + }, + { + "name": "utopia-php/psr7", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/psr7.git", + "reference": "115753c36194d53abe5587d383810724ac3a782d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/psr7/zipball/115753c36194d53abe5587d383810724ac3a782d", + "reference": "115753c36194d53abe5587d383810724ac3a782d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-simplexml": "Required to decode XML responses with Response::xml()." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Psr7\\": "src/Psr7/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PSR-7 HTTP message implementations and PSR-17 factories for Utopia", + "keywords": [ + "http", + "php", + "psr-17", + "psr-7", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/psr7/issues", + "source": "https://github.com/utopia-php/psr7/tree/0.2.0" + }, + "time": "2026-07-06T12:40:23+00:00" + }, + { + "name": "utopia-php/span", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/span.git", + "reference": "7969d920044c1e106d9eede414e2d6e8df206ff5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/span/zipball/7969d920044c1e106d9eede414e2d6e8df206ff5", + "reference": "7969d920044c1e106d9eede414e2d6e8df206ff5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "swoole/ide-helper": "^5.0" + }, + "suggest": { + "ext-swoole": "Required for coroutine-based storage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Span\\": "src/Span/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple span tracing library for PHP with coroutine support", + "support": { + "issues": "https://github.com/utopia-php/span/issues", + "source": "https://github.com/utopia-php/span/tree/4.1.0" + }, + "time": "2026-07-24T08:46:29+00:00" + }, + { + "name": "utopia-php/telemetry", + "version": "0.4.5", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/telemetry.git", + "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/139943bffcd4f6dd8fb9ed247f946a1d151b006a", + "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a", + "shasum": "" + }, + "require": { + "ext-protobuf": "*", + "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-sockets": "Required for the Swoole transport implementation", + "ext-swoole": "Required for the Swoole transport implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Telemetry\\": "src/Telemetry" + } + }, + "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.5" + }, + "time": "2026-07-08T11:07:25+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.30.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "72a0540d1aa10b6c146bda2a22f3ae003123c0ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/72a0540d1aa10b6c146bda2a22f3ae003123c0ea", + "reference": "72a0540d1aa10b6c146bda2a22f3ae003123c0ea", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.17", + "illuminate/view": "^12.64.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.21", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.7" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-07-28T20:48:56+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.6", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "OndΕ™ej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-26T21:22:49+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/048a5c12bdb4580f4767ce2761793a16b170fbe4", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-07-30T17:01:07+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5d2afe181339a56348ef9a80fa7eb806b7eae508", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.6" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T14:00:09+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-08-01T04:27:14+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-03T05:58:12+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "swoole/ide-helper", + "version": "6.2.2", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "ad91a281e135f498426fb2e5297376581a4e2dca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/ad91a281e135f498426fb2e5297376581a4e2dca", + "reference": "ad91a281e135f498426fb2e5297376581a4e2dca", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Team Swoole", + "email": "team@swoole.com" + } + ], + "description": "IDE help files for Swoole.", + "support": { + "issues": "https://github.com/swoole/ide-helper/issues", + "source": "https://github.com/swoole/ide-helper/tree/6.2.2" + }, + "time": "2026-07-25T08:15:29+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.5", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c33372 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +name: feed + +services: + tests: + container_name: feed-tests + image: feed-tests:${PHP_VERSION:-8.5} + build: + context: . + dockerfile: Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.5} + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + networks: + - feed + volumes: + - ./phpunit.xml:/code/phpunit.xml + - ./phpstan.neon:/code/phpstan.neon + - ./pint.json:/code/pint.json + - ./src:/code/src + - ./tests:/code/tests + depends_on: + redis: + condition: service_healthy + + redis: + image: redis:7.2-alpine + container_name: feed-redis + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 20 + networks: + - feed + +networks: + feed: diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..bd1ec2f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src + - tests + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d60e668 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,57 @@ + + + + + + + ./src + + + + + + ./tests/Feed/Unit + + + ./tests/Feed/Producer/MemoryTest.php + ./tests/Feed/Server/MemoryTest.php + ./tests/Feed/Consumer/MemoryTest.php + ./tests/Feed/Cursor/MemoryTest.php + + + ./tests/Feed/Producer/CacheTest.php + ./tests/Feed/Server/CacheTest.php + ./tests/Feed/Consumer/CacheTest.php + ./tests/Feed/Cursor/CacheTest.php + + + ./tests/Feed/Consumer/HttpTest.php + + + ./tests/Feed/Producer/RedisTest.php + ./tests/Feed/Server/RedisTest.php + ./tests/Feed/Consumer/RedisTest.php + ./tests/Feed/Cursor/RedisTest.php + + + ./tests/Feed/Producer/PoolTest.php + ./tests/Feed/Server/PoolTest.php + ./tests/Feed/Consumer/PoolTest.php + ./tests/Feed/Cursor/PoolTest.php + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..8b8058b --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "psr12", + "rules": { + "declare_strict_types": true + } +} diff --git a/src/Feed/Appendable.php b/src/Feed/Appendable.php new file mode 100644 index 0000000..3a33bf4 --- /dev/null +++ b/src/Feed/Appendable.php @@ -0,0 +1,16 @@ + + */ +final class Batch implements \Countable, \IteratorAggregate +{ + /** What a feed response's `Content-Type` carries. */ + public const string MEDIA_TYPE = Readable::MEDIA_TYPE; + + private const string CACHE_IMMUTABLE = 'max-age=31536000'; + private const string CACHE_NONE = 'no-store'; + + /** + * @param list $events + * @param int $limit The effective limit β€” the clamped value the feed used to build this batch. + */ + public function __construct( + private readonly array $events, + private readonly int $limit, + ) { + } + + /** @return \ArrayIterator */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->events); + } + + public function count(): int + { + return \count($this->events); + } + + public function isEmpty(): bool + { + return $this->events === []; + } + + public function lastId(): ?string + { + $count = \count($this->events); + + return $count === 0 ? null : $this->events[$count - 1]->id; + } + + /** + * A full batch is settled history, so it may be cached forever. Anything + * short is the live end of the feed and will grow β€” an empty batch + * included: zero events is a caught-up consumer, and caching that would + * pin the consumer at its position forever. + */ + public function cacheControl(bool $public = false): string + { + $count = \count($this->events); + + if ($count < $this->limit || $count === 0) { + return self::CACHE_NONE; + } + + return ($public ? 'public, ' : 'private, ') . self::CACHE_IMMUTABLE; + } + + /** + * A batch on the wire is a plain array of CloudEvents β€” no envelope. An + * empty feed serializes to `[]`, which the spec reads as "you are caught up". + * + * @return list> + */ + public function toArray(): array + { + return \array_map(static fn (CloudEvent $event): array => $event->toArray(), $this->events); + } +} diff --git a/src/Feed/Consumer.php b/src/Feed/Consumer.php new file mode 100644 index 0000000..f09d5f9 --- /dev/null +++ b/src/Feed/Consumer.php @@ -0,0 +1,164 @@ +feed = new Remote($source, $feed); + } else { + if ($feed !== '' && $feed !== $source->getName()) { + throw new Exception\Invalid("The source already names its feed {$source->getName()}, which {$feed} contradicts"); + } + + $this->feed = $source; + } + } + + public function getName(): string + { + return $this->name; + } + + public function consume(callable $handler): int + { + $moved = $this->moved; + + $events = $this->feed->poll( + $this->position() ?? $this->origin(), + \max(1, \min($this->batch, Readable::MAX_BATCH)), + \max(0, \min($this->timeout, Readable::MAX_TIMEOUT)), + ); + + if ($events === []) { + return 0; + } + + $handled = 0; + $processed = null; + $failure = null; + + foreach ($events as $event) { + try { + $handler($event); + } catch (\Throwable $error) { + $failure = $error; + break; + } + + $processed = $event->id; + $handled++; + } + + if ($processed !== null && $this->moved === $moved) { + $expected = $this->position; + $this->position = $processed; + + // Conditional for the same reason as the $moved guard, but across + // instances: a save lands only if the position is still where this + // run started. Refused means another instance moved it β€” progress, + // a seek, or a reset β€” and that newer decision stands. + if (!$this->cursor->advance($this->feed->getName(), $this->name, $processed, $expected)) { + $this->position = null; + $this->restored = false; + } + } + + if ($failure !== null) { + throw $failure; + } + + return $handled; + } + + private function origin(): ?string + { + return $this->start === self::START_TIP ? Readable::TIP : null; + } + + public function position(): ?string + { + if (!$this->restored) { + $this->position = $this->cursor->load($this->feed->getName(), $this->name); + $this->restored = true; + } + + return $this->position; + } + + /** + * Move the position by hand. + * + * What counts as a usable id is the feed's to say: a local {@see Store} + * mints `{ms}-{seq}` positions and pages by decoding them, while a remote + * producer is the authority on its own (http-feeds endpoints commonly use + * UUIDs). Both refuse the tip sentinel, which is a start, not a position. + * + * @throws Exception\Invalid When $eventId cannot be a position on this feed + * @throws Exception When the cursor store cannot be written + */ + public function seek(string $eventId): void + { + $usable = $this->feed instanceof Store + ? Id::isValid($eventId) + : $eventId !== '' && $eventId !== Readable::TIP; + + if (!$usable) { + throw new Exception\Invalid('Invalid feed event id: ' . $eventId); + } + + $this->cursor->save($this->feed->getName(), $this->name, $eventId); + + $this->position = $eventId; + $this->restored = true; + $this->moved++; + } + + public function reset(): void + { + $this->cursor->reset($this->feed->getName(), $this->name); + + $this->position = null; + $this->restored = true; + $this->moved++; + } +} diff --git a/src/Feed/Cursor.php b/src/Feed/Cursor.php new file mode 100644 index 0000000..e2bd591 --- /dev/null +++ b/src/Feed/Cursor.php @@ -0,0 +1,64 @@ +load($feed, $consumer) !== $expected) { + return false; + } + + $this->save($feed, $consumer, $eventId); + + return true; + } + + /** + * The gate every cursor operation goes through, so no adapter builds a key + * of its own and skips the check. + * + * @throws Invalid When either name is empty. + */ + protected function key(string $feed, string $consumer): string + { + if ($feed === '' || $consumer === '') { + throw new Invalid('Cursor requires a feed and a consumer name'); + } + + return Key::cursor($feed, $consumer); + } +} diff --git a/src/Feed/Cursor/Cache.php b/src/Feed/Cursor/Cache.php new file mode 100644 index 0000000..b49dd0a --- /dev/null +++ b/src/Feed/Cursor/Cache.php @@ -0,0 +1,64 @@ +key($feed, $consumer); + + try { + /** @var mixed $cursor */ + $cursor = $this->cache->load($key, $this->ttl); + } catch (\Throwable $error) { + throw new Transport("Failed to load the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + + return \is_string($cursor) && $cursor !== '' ? $cursor : null; + } + + public function save(string $feed, string $consumer, string $eventId): void + { + $key = $this->key($feed, $consumer); + + try { + $saved = $this->cache->save($key, $eventId); + } catch (\Throwable $error) { + throw new Transport("Failed to save the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + + // A cache reports a failed write by returning false rather than raising. + if ($saved === false) { + throw new Transport("Failed to save the {$consumer} cursor on the {$feed} feed"); + } + } + + public function reset(string $feed, string $consumer): void + { + $key = $this->key($feed, $consumer); + + try { + // purge()'s false is not checked: it also means "was never there", + // which is the ordinary case for a consumer with no position yet. + $this->cache->purge($key); + } catch (\Throwable $error) { + throw new Transport("Failed to reset the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + } +} diff --git a/src/Feed/Cursor/Memory.php b/src/Feed/Cursor/Memory.php new file mode 100644 index 0000000..5557a58 --- /dev/null +++ b/src/Feed/Cursor/Memory.php @@ -0,0 +1,28 @@ + */ + private array $cursors = []; + + public function load(string $feed, string $consumer): ?string + { + return $this->cursors[$this->key($feed, $consumer)] ?? null; + } + + public function save(string $feed, string $consumer, string $eventId): void + { + $this->cursors[$this->key($feed, $consumer)] = $eventId; + } + + public function reset(string $feed, string $consumer): void + { + unset($this->cursors[$this->key($feed, $consumer)]); + } +} diff --git a/src/Feed/Cursor/None.php b/src/Feed/Cursor/None.php new file mode 100644 index 0000000..c8807a1 --- /dev/null +++ b/src/Feed/Cursor/None.php @@ -0,0 +1,36 @@ +key($feed, $consumer); + + return null; + } + + public function save(string $feed, string $consumer, string $eventId): void + { + $this->key($feed, $consumer); + } + + public function reset(string $feed, string $consumer): void + { + $this->key($feed, $consumer); + } + + /** Nothing is stored, so nothing can conflict: the caller's own memory is the only record. */ + public function advance(string $feed, string $consumer, string $eventId, ?string $expected): bool + { + $this->key($feed, $consumer); + + return true; + } +} diff --git a/src/Feed/Cursor/Pool.php b/src/Feed/Cursor/Pool.php new file mode 100644 index 0000000..d91f050 --- /dev/null +++ b/src/Feed/Cursor/Pool.php @@ -0,0 +1,42 @@ + $pool + */ + public function __construct(protected readonly UtopiaPool $pool) + { + } + + public function load(string $feed, string $consumer): ?string + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): ?string => (new Redis($redis))->load($feed, $consumer)); + } + + public function save(string $feed, string $consumer, string $eventId): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($feed, $consumer, $eventId): void { + (new Redis($redis))->save($feed, $consumer, $eventId); + }); + } + + public function reset(string $feed, string $consumer): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($feed, $consumer): void { + (new Redis($redis))->reset($feed, $consumer); + }); + } + + public function advance(string $feed, string $consumer, string $eventId, ?string $expected): bool + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): bool => (new Redis($redis))->advance($feed, $consumer, $eventId, $expected)); + } +} diff --git a/src/Feed/Cursor/Redis.php b/src/Feed/Cursor/Redis.php new file mode 100644 index 0000000..61eca51 --- /dev/null +++ b/src/Feed/Cursor/Redis.php @@ -0,0 +1,109 @@ +key($feed, $consumer); + + try { + $this->redis->clearLastError(); + + /** @var mixed $cursor */ + $cursor = $this->redis->get($key); + } catch (\RedisException $error) { + if (self::wrongType($error->getMessage())) { + return $this->loadStream($key, $consumer); + } + + throw new Transport("Failed to load the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + + // phpredis reports a refusal either by throwing or by returning + // `false` with the text in getLastError(); check both paths. + if ($cursor === false && self::wrongType($this->lastError())) { + return $this->loadStream($key, $consumer); + } + + return \is_string($cursor) && $cursor !== '' ? $cursor : null; + } + + public function save(string $feed, string $consumer, string $eventId): void + { + try { + $this->redis->set($this->key($feed, $consumer), $eventId); + } catch (\RedisException $error) { + throw new Transport("Failed to save the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + } + + public function reset(string $feed, string $consumer): void + { + try { + $this->redis->del($this->key($feed, $consumer)); + } catch (\RedisException $error) { + throw new Transport("Failed to reset the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + } + + /** + * A position kept as a one-entry stream by an earlier version, with the + * position as the entry's id. The next {@see save()} converts the key. + * + * @throws Transport When the key holds neither a string nor a stream. + */ + private function loadStream(string $key, string $consumer): ?string + { + try { + $this->redis->clearLastError(); + + $entries = $this->redis->xRevRange($key, '+', '-', 1); + } catch (\RedisException $error) { + throw new Transport("Failed to load the {$consumer} cursor: {$error->getMessage()}", previous: $error); + } + + if ($entries === false) { + throw new Transport("Failed to load the {$consumer} cursor: " . ($this->lastError() ?: 'Redis command failed')); + } + + if (!\is_array($entries) || $entries === []) { + return null; + } + + // The position is the entry's id, not its payload. + $id = \array_key_first($entries); + + return \is_string($id) && $id !== '' ? $id : null; + } + + /** The last command's error text, cleared on the way out. */ + private function lastError(): string + { + $error = $this->redis->getLastError(); + $this->redis->clearLastError(); + + return \is_string($error) ? $error : ''; + } + + /** Whether Redis refused because the key holds another type. */ + private static function wrongType(string $error): bool + { + return \str_contains($error, 'WRONGTYPE'); + } +} diff --git a/src/Feed/Exception.php b/src/Feed/Exception.php new file mode 100644 index 0000000..31e053f --- /dev/null +++ b/src/Feed/Exception.php @@ -0,0 +1,10 @@ + $candidates A decoded event, or just its extension attributes. + * @return array + */ + public static function filter(array $candidates): array + { + $extensions = []; + + /** @var mixed $value */ + foreach ($candidates as $name => $value) { + if (\in_array($name, self::ATTRIBUTES, true)) { + continue; + } + + // A digits-only name is legal, and an integer key in PHP. + if (\preg_match('/^[a-z0-9]+$/', (string) $name) !== 1) { + continue; + } + + if (\is_bool($value) || \is_int($value) || \is_string($value)) { + $extensions[$name] = $value; + } + } + + return $extensions; + } +} diff --git a/src/Feed/Id.php b/src/Feed/Id.php new file mode 100644 index 0000000..91c252d --- /dev/null +++ b/src/Feed/Id.php @@ -0,0 +1,39 @@ +store->getName(); + } + + /** + * @throws Exception\Invalid When $type is empty or $data cannot be encoded. + * @throws Exception When the backend rejects the event. + */ + public function produce(string $type, mixed $data = [], string $subject = ''): string + { + return $this->publish(new CloudEvent( + type: $type, + source: $this->source, + id: '', + subject: $subject === '' ? null : $subject, + data: $data, + )); + } + + /** + * Publish a prepared event. + * + * Three attributes are replaced whatever the event arrived with: `source` + * (a producer only speaks for itself, so a relayed event is republished as + * this service's), `id` (the store assigns it β€” it is also the position) + * and a missing `time`. `specversion` reads back as `1.0`; the rest is + * published as prepared. + * + * @throws Exception\Invalid When the event has no type or cannot be encoded. + * @throws Exception When the backend rejects the event. + */ + public function publish(CloudEvent $event): string + { + if ($event->type === '') { + throw new Exception\Invalid('Feed event type is required'); + } + + // Rebuilt attribute by attribute to have untouched copy + $event = new CloudEvent( + type: $event->type, + source: $this->source, + id: $event->id, + specversion: $event->specversion, + subject: $event->subject, + time: $event->time === null || $event->time === '' ? self::now() : $event->time, + datacontenttype: $event->datacontenttype, + data: $event->data, + dataschema: $event->dataschema, + extensions: $event->extensions, + ); + + return $this->store->append($event); + } + + /** The current time in the RFC 3339 format the spec requires. */ + private static function now(): string + { + return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'); + } +} diff --git a/src/Feed/Readable.php b/src/Feed/Readable.php new file mode 100644 index 0000000..0022b5d --- /dev/null +++ b/src/Feed/Readable.php @@ -0,0 +1,46 @@ + + * @throws Exception When the events cannot be read. + */ + public function read(?string $lastEventId, int $limit): array; + + /** + * @return list + * @throws Exception When the events cannot be read. + */ + public function poll(?string $lastEventId, int $limit, int $timeout): array; + + /** + * @throws Exception When the tip cannot be read, or only its owner can resolve it. + */ + public function tip(): ?string; +} diff --git a/src/Feed/Remote.php b/src/Feed/Remote.php new file mode 100644 index 0000000..2d5cc65 --- /dev/null +++ b/src/Feed/Remote.php @@ -0,0 +1,215 @@ +requests = new RequestFactory(); + } + + public function getName(): string + { + return $this->name; + } + + public function tip(): ?string + { + throw new Unsupported("The {$this->name} feed is remote; its producer resolves the tip"); + } + + public function read(?string $lastEventId = null, int $limit = self::MAX_BATCH): array + { + return $this->fetch($lastEventId, $limit, 0); + } + + public function poll(?string $lastEventId = null, int $limit = self::MAX_BATCH, int $timeout = 0): array + { + return $this->fetch($lastEventId, $limit, $timeout); + } + + /** + * @return list + */ + private function fetch(?string $lastEventId, int $limit, int $timeout): array + { + $request = $this->requests->query( + Method::GET, + \rawurlencode($this->name), + self::query($lastEventId, $limit, $timeout), + [Header::ACCEPT => self::MEDIA_TYPE], + ); + + $client = $timeout > 0 + ? $this->client->withTimeout(($timeout + self::TIMEOUT_MARGIN) / 1000) + : $this->client; + + try { + $response = $client->sendRequest($request); + } catch (ClientExceptionInterface $error) { + throw new Transport("Failed to read the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + $status = $response->getStatusCode(); + + if ($status >= 400) { + throw new Transport( + "Reading the {$this->name} feed failed with status {$status}", + $status, + ); + } + + $raw = (string) $response->getBody(); + + try { + $body = \json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + throw new Transport("The {$this->name} feed returned a body that is not JSON: {$error->getMessage()}", previous: $error); + } + + // json_decode collapses `{}` and `[]` into the same empty array, and + // only one of them is a batch: an empty object is a non-feed answering, + // not "caught up". + if ($body === [] && !\str_starts_with(\ltrim($raw), '[')) { + throw new Invalid('Expected a feed batch, got an object'); + } + + return self::decode($body); + } + + /** + * @return array + */ + private static function query(?string $lastEventId, int $limit, int $timeout): array + { + $query = []; + + if ($lastEventId !== null && $lastEventId !== '') { + $query[self::PARAM_LAST_EVENT_ID] = $lastEventId; + } + + if ($limit > 0) { + $query[self::PARAM_LIMIT] = $limit; + } + + if ($timeout > 0) { + $query[self::PARAM_TIMEOUT] = $timeout; + } + + return $query; + } + + /** + * @return list + * @throws Invalid When the payload is not a batch, or its first entry cannot be read. + */ + private static function decode(mixed $payload): array + { + if (!\is_array($payload)) { + throw new Invalid('Expected a feed batch, got ' . \get_debug_type($payload)); + } + + if (!\array_is_list($payload)) { + throw new Invalid('Expected a feed batch as a plain array of events'); + } + + $events = []; + + /** @var mixed $event */ + foreach ($payload as $event) { + try { + if (!\is_array($event)) { + throw new Invalid('Feed batch contains an entry that is not an event'); + } + + $events[] = self::event($event); + } catch (Invalid | \InvalidArgumentException $error) { + if ($events === []) { + throw $error instanceof Invalid + ? $error + : new Invalid('Feed batch contains an event that cannot be read: ' . $error->getMessage(), previous: $error); + } + + break; + } + } + + return $events; + } + + /** + * @param array $raw + */ + private static function event(array $raw): CloudEvent + { + foreach (['specversion', 'id', 'type', 'source'] as $required) { + if (!isset($raw[$required]) || !\is_string($raw[$required]) || $raw[$required] === '') { + throw new Invalid('Feed event is missing ' . ($required === 'id' ? 'an id' : 'a ' . $required)); + } + } + + $extensions = Extensions::filter($raw); + + return new CloudEvent( + type: $raw['type'], + source: $raw['source'], + id: $raw['id'], + specversion: $raw['specversion'], + subject: self::optional($raw, 'subject'), + time: self::optional($raw, 'time'), + datacontenttype: self::optional($raw, 'datacontenttype'), + data: $raw['data'] ?? null, + dataschema: self::optional($raw, 'dataschema'), + // The docblock wants array, but a digit-only + // extension name β€” legal per the spec β€” is an integer key in PHP. + // @phpstan-ignore argument.type + extensions: $extensions, + ); + } + + /** + * @param array $raw + */ + private static function optional(array $raw, string $attribute): ?string + { + $value = $raw[$attribute] ?? null; + + return \is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Feed/Server.php b/src/Feed/Server.php new file mode 100644 index 0000000..c77158a --- /dev/null +++ b/src/Feed/Server.php @@ -0,0 +1,79 @@ +store->getName(); + } + + /** + * @throws Exception + */ + public function tip(): ?string + { + return $this->store->tip(); + } + + public function read(?string $lastEventId = null, int $limit = Readable::MAX_BATCH): Batch + { + $limit = \max(1, \min($limit, Readable::MAX_BATCH)); + + return new Batch($this->store->read($lastEventId, $limit), $limit); + } + + public function poll(?string $lastEventId = null, int $limit = Readable::MAX_BATCH, int $timeout = 0): Batch + { + $limit = \max(1, \min($limit, Readable::MAX_BATCH)); + + return new Batch( + $this->store->poll($lastEventId, $limit, \max(0, \min($timeout, Readable::MAX_TIMEOUT))), + $limit, + ); + } + + /** + * @param array $query The request's query parameters, string values included. + * @throws Exception\Invalid When `lastEventId` is present but is neither a feed position nor the tip sentinel + */ + public function serve(array $query): Batch + { + /** @var mixed $lastEventId */ + $lastEventId = $query[self::PARAM_LAST_EVENT_ID] ?? null; + + // Absent and empty mean "from the oldest retained event"; anything else + // must be a position or the sentinel, non-strings included. PHP parses + // `?lastEventId[]=1-0` into an array, and reading that as absent would + // answer a malformed parameter with a full replay of the feed. + if ($lastEventId === null || $lastEventId === '') { + $lastEventId = null; + } elseif (!\is_string($lastEventId)) { + throw new Exception\Invalid('Invalid lastEventId: expected a string, got ' . \get_debug_type($lastEventId)); + } elseif ($lastEventId !== Readable::TIP && !Id::isValid($lastEventId)) { + throw new Exception\Invalid('Invalid lastEventId: ' . $lastEventId); + } + + // `limit` and `timeout` stay forgiving: neither can cause a wrong answer. + $limit = $query[self::PARAM_LIMIT] ?? null; + $timeout = $query[self::PARAM_TIMEOUT] ?? null; + + return $this->poll( + $lastEventId, + \is_numeric($limit) ? (int) $limit : Readable::MAX_BATCH, + \is_numeric($timeout) ? (int) $timeout : 0, + ); + } +} diff --git a/src/Feed/Store.php b/src/Feed/Store.php new file mode 100644 index 0000000..71c1417 --- /dev/null +++ b/src/Feed/Store.php @@ -0,0 +1,150 @@ +name; + } + + /** The backend key this feed's events live under. */ + protected function key(): string + { + return Key::feed($this->name); + } + + /** @return list */ + abstract public function read(?string $lastEventId, int $limit): array; + + abstract public function tip(): ?string; + + protected function resolve(?string $lastEventId): ?string + { + return $lastEventId === Readable::TIP ? $this->tip() : $lastEventId; + } + + /** + * @return list + */ + public function poll(?string $lastEventId, int $limit, int $timeout): array + { + $lastEventId = $this->resolve($lastEventId); + + $deadline = \microtime(true) + $timeout / 1000; + + while (true) { + $events = $this->read($lastEventId, $limit); + + $remaining = $deadline - \microtime(true); + + if ($events !== [] || $remaining <= 0) { + return $events; + } + + \usleep((int) \min($this->pollInterval * 1000, \ceil($remaining * 1_000_000))); + } + } + + /** + * Every attribute a CloudEvent carries, flattened to strings, with the + * empty string for an absent one β€” the spec has no null attribute values, + * so it cannot collide. `id` and `specversion` are left out: the store + * assigns the first, and `1.0` is the only version {@see self::decode()} + * can restore. + * + * @return array + */ + protected static function encode(CloudEvent $event): array + { + return [ + 'type' => $event->type, + 'source' => $event->source, + 'subject' => $event->subject ?? '', + 'datacontenttype' => $event->datacontenttype ?? '', + 'dataschema' => $event->dataschema ?? '', + 'time' => $event->time ?? '', + 'data' => self::json($event->data, 'data'), + 'extensions' => self::json($event->extensions, 'extensions'), + ]; + } + + /** @param array $fields */ + protected static function decode(string $id, array $fields): CloudEvent + { + $extensions = \json_decode(self::field($fields, 'extensions'), true); + + $event = [ + 'specversion' => '1.0', + 'id' => $id, + 'type' => self::field($fields, 'type'), + 'source' => self::field($fields, 'source'), + 'data' => \json_decode(self::field($fields, 'data'), true), + ]; + + foreach (['subject', 'datacontenttype', 'dataschema', 'time'] as $optional) { + $value = self::field($fields, $optional); + + if ($value !== '') { + $event[$optional] = $value; + } + } + + // Filtered, not merged verbatim: a read decodes every entry, so one a + // foreign writer poisoned would fail every read past it forever. + $event += Extensions::filter(\is_array($extensions) ? $extensions : []); + + try { + // The docblock wants array, but a digit-only + // extension name β€” legal per the spec β€” is an integer key in PHP. + // @phpstan-ignore argument.type + return CloudEvent::fromArray($event); + } catch (\InvalidArgumentException $error) { + throw new Invalid("Feed entry {$id} could not be read as an event: {$error->getMessage()}", previous: $error); + } + } + + private static function json(mixed $value, string $attribute): string + { + try { + return \json_encode($value, JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + throw new Invalid("Feed event {$attribute} must be JSON encodable: {$error->getMessage()}", previous: $error); + } + } + + /** @param array $fields */ + private static function field(array $fields, string $key): string + { + $value = $fields[$key] ?? ''; + + return \is_scalar($value) ? (string) $value : ''; + } +} diff --git a/src/Feed/Store/Cache.php b/src/Feed/Store/Cache.php new file mode 100644 index 0000000..c92380b --- /dev/null +++ b/src/Feed/Store/Cache.php @@ -0,0 +1,163 @@ +load(); + + $last = $entries === [] ? null : $entries[\count($entries) - 1]; + [$timestamp, $sequence] = $last === null ? [0, -1] : Id::decode($last['id']); + + $now = (int) \floor(\microtime(true) * 1000); + $id = $now > $timestamp ? Id::encode($now, 0) : Id::encode($timestamp, $sequence + 1); + + $entries[] = ['id' => $id, 'fields' => self::encode($event)]; + + if (\count($entries) > $this->maxSize) { + $entries = \array_slice($entries, -$this->maxSize); + } + + // The marker first, so it can be ahead of the feed but never behind: + // behind, it would report a caught-up consumer and lose the event. + $this->write(Key::tip($this->name), $id); + $this->write($this->key(), $entries); + + return $id; + } + + /** + * @param string|array $value + * @throws Transport When the write fails, either way a cache can. + */ + private function write(string $key, string|array $value): void + { + try { + $saved = $this->cache->save($key, $value); + } catch (\Throwable $error) { + throw new Transport("Failed to append to the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if ($saved === false) { + throw new Transport("Failed to append to the {$this->name} feed"); + } + } + + public function tip(): ?string + { + $entries = $this->load(); + + return $entries === [] ? null : $entries[\count($entries) - 1]['id']; + } + + public function read(?string $lastEventId, int $limit): array + { + $lastEventId = $this->resolve($lastEventId); + + if ($lastEventId !== null && $this->caughtUp($lastEventId)) { + return []; + } + + $after = $lastEventId === null ? null : Id::decode($lastEventId); + + $events = []; + + foreach ($this->load() as $entry) { + if ($after !== null && Id::decode($entry['id']) <= $after) { + continue; + } + + $events[] = self::decode($entry['id'], $entry['fields']); + + if (\count($events) >= $limit) { + break; + } + } + + return $events; + } + + /** + * Whether the feed provably holds nothing after $lastEventId, from the tip + * marker alone β€” so a caught-up long poll does not load the whole feed per + * tick. Only ever used to skip a read, never to answer one. + * + * @throws Transport When the cache backend cannot be reached. + */ + private function caughtUp(string $lastEventId): bool + { + try { + /** @var mixed $tip */ + $tip = $this->cache->load(Key::tip($this->name), $this->ttl); + } catch (\Throwable $error) { + throw new Transport("Failed to read the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if (!\is_string($tip) || !Id::isValid($tip)) { + return false; + } + + return Id::decode($tip) <= Id::decode($lastEventId); + } + + /** + * @return list}> + * @throws Transport When the cache backend cannot be reached. + */ + private function load(): array + { + try { + /** @var mixed $stored */ + $stored = $this->cache->load($this->key(), $this->ttl); + } catch (\Throwable $error) { + throw new Transport("Failed to read the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if (!\is_array($stored)) { + return []; + } + + $entries = []; + + /** @var mixed $entry */ + foreach ($stored as $entry) { + if (!\is_array($entry) + || !isset($entry['id'], $entry['fields']) + || !\is_string($entry['id']) + || !\is_array($entry['fields'])) { + return []; + } + + $entries[] = ['id' => $entry['id'], 'fields' => $entry['fields']]; + } + + return $entries; + } +} diff --git a/src/Feed/Store/Memory.php b/src/Feed/Store/Memory.php new file mode 100644 index 0000000..11b2659 --- /dev/null +++ b/src/Feed/Store/Memory.php @@ -0,0 +1,79 @@ + */ + private array $events = []; + + private int $timestamp = 0; + private int $sequence = -1; + + public function __construct( + string $name, + int $maxSize = self::MAX_SIZE, + int $pollInterval = self::POLL_INTERVAL, + ) { + parent::__construct($name, $maxSize, $pollInterval); + } + + public function append(CloudEvent $event): string + { + $now = (int) \floor(\microtime(true) * 1000); + + if ($now > $this->timestamp) { + $this->timestamp = $now; + $this->sequence = 0; + } else { + $this->sequence++; + } + + $id = Id::encode($this->timestamp, $this->sequence); + + $this->events[] = self::decode($id, self::encode($event)); + + if (\count($this->events) > $this->maxSize) { + $this->events = \array_slice($this->events, -$this->maxSize); + } + + return $id; + } + + public function tip(): ?string + { + $count = \count($this->events); + + return $count === 0 ? null : $this->events[$count - 1]->id; + } + + public function read(?string $lastEventId, int $limit): array + { + $lastEventId = $this->resolve($lastEventId); + + $after = $lastEventId === null ? null : Id::decode($lastEventId); + + $events = []; + + foreach ($this->events as $event) { + if ($after !== null && Id::decode($event->id) <= $after) { + continue; + } + + $events[] = $event; + + if (\count($events) >= $limit) { + break; + } + } + + return $events; + } +} diff --git a/src/Feed/Store/None.php b/src/Feed/Store/None.php new file mode 100644 index 0000000..ee7a31f --- /dev/null +++ b/src/Feed/Store/None.php @@ -0,0 +1,33 @@ +name} feed"); + } + + public function tip(): ?string + { + throw new Unsupported("No feed backend is configured for the {$this->name} feed"); + } + + public function read(?string $lastEventId, int $limit): array + { + throw new Unsupported("No feed backend is configured for the {$this->name} feed"); + } +} diff --git a/src/Feed/Store/Pool.php b/src/Feed/Store/Pool.php new file mode 100644 index 0000000..c2afcb3 --- /dev/null +++ b/src/Feed/Store/Pool.php @@ -0,0 +1,51 @@ + $pool + */ + public function __construct( + protected readonly UtopiaPool $pool, + string $name, + int $maxSize = self::MAX_SIZE, + int $pollInterval = self::POLL_INTERVAL, + ) { + parent::__construct($name, $maxSize, $pollInterval); + } + + private function inner(\Redis|\RedisCluster $redis): Redis + { + return new Redis($redis, $this->name, $this->maxSize, $this->pollInterval); + } + + public function append(CloudEvent $event): string + { + return $this->pool->use( + fn (\Redis|\RedisCluster $redis): string => $this->inner($redis)->append($event) + ); + } + + public function tip(): ?string + { + return $this->pool->use( + fn (\Redis|\RedisCluster $redis): ?string => $this->inner($redis)->tip() + ); + } + + public function read(?string $lastEventId, int $limit): array + { + return $this->pool->use( + fn (\Redis|\RedisCluster $redis): array => $this->inner($redis)->read($lastEventId, $limit) + ); + } +} diff --git a/src/Feed/Store/Redis.php b/src/Feed/Store/Redis.php new file mode 100644 index 0000000..01f16ec --- /dev/null +++ b/src/Feed/Store/Redis.php @@ -0,0 +1,83 @@ +redis->xAdd($this->key(), '*', self::encode($event), $this->maxSize, true); + } catch (\RedisException $error) { + throw new Transport("Failed to append to the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if (!\is_string($id) || $id === '') { + throw new Transport("Failed to append to the {$this->name} feed"); + } + + return $id; + } + + public function tip(): ?string + { + try { + $entries = $this->redis->xRevRange($this->key(), '+', '-', 1); + } catch (\RedisException $error) { + throw new Transport("Failed to read the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if (!\is_array($entries) || $entries === []) { + return null; + } + + return (string) \array_key_first($entries); + } + + public function read(?string $lastEventId, int $limit): array + { + $lastEventId = $this->resolve($lastEventId); + + $start = $lastEventId === null ? '-' : Id::after($lastEventId); + + try { + $entries = $this->redis->xRange($this->key(), $start, '+', $limit); + } catch (\RedisException $error) { + throw new Transport("Failed to read the {$this->name} feed: {$error->getMessage()}", previous: $error); + } + + if (!\is_array($entries)) { + return []; + } + + $events = []; + + /** @var mixed $fields */ + foreach ($entries as $id => $fields) { + if (!\is_array($fields)) { + continue; + } + + $events[] = self::decode((string) $id, $fields); + } + + return $events; + } +} diff --git a/tests/Feed/Consumer/Base.php b/tests/Feed/Consumer/Base.php new file mode 100644 index 0000000..da5ab0d --- /dev/null +++ b/tests/Feed/Consumer/Base.php @@ -0,0 +1,641 @@ +name = 'test-' . \bin2hex(\random_bytes(8)); + $this->store = new MemoryStore($this->name); + $this->producer = new Producer($this->store, 'urn:test'); + $this->cursor = $this->cursor(); + } + + protected function consumer( + string $name = 'invalidator', + int $batch = Consumer::BATCH, + int $timeout = 0, + string $start = Consumer::START_OLDEST, + (Store&Appendable)|null $store = null, + ): Consumer { + $store ??= $this->store; + + return new Consumer($this->source($store), $this->cursor, $name, feed: $store->getName(), batch: $batch, timeout: $timeout, start: $start); + } + + /** + * @param-out int $count + * @return list + */ + protected function drain(Consumer $consumer, ?int &$count = null): array + { + $seen = []; + $count = $consumer->consume(function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }); + + return $seen; + } + + public function testHandlesEachEventAndAdvancesPastTheLastOne(): void + { + $this->producer->produce('a'); + $last = $this->producer->produce('b'); + + $consumer = $this->consumer(); + + $this->assertSame(['a', 'b'], $this->drain($consumer, $count)); + $this->assertSame(2, $count); + $this->assertSame($last, $this->cursor->load($this->name, 'invalidator')); + $this->assertSame($last, $consumer->position()); + } + + public function testCaughtUpConsumerDoesNothing(): void + { + $this->producer->produce('a'); + + $consumer = $this->consumer(); + $consumer->consume(fn (CloudEvent $event) => null); + + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + } + + public function testResumesFromTheStoredPosition(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->cursor->save($this->name, 'invalidator', $first); + + $this->assertSame(['b'], $this->drain($this->consumer())); + } + + /** + * A consumer that has never run starts at the oldest retained event, not + * at the tip β€” otherwise the first event a feed ever carries is the one + * event that is guaranteed to be dropped. + */ + public function testAConsumerWithNoPositionStartsAtTheOldestEventNotTheTip(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertSame(['a', 'b'], $this->drain($this->consumer())); + } + + public function testTheConsumerOnlyEverSeesEachEventOnce(): void + { + foreach (\range(1, 5) as $i) { + $this->producer->produce('event-' . $i); + } + + $consumer = $this->consumer(); + $seen = []; + $handler = function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }; + + $consumer->consume($handler); + $consumer->consume($handler); + + $this->producer->produce('event-6'); + $consumer->consume($handler); + + $this->assertSame( + ['event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], + $seen, + ); + } + + /** + * The restart property: a fresh Consumer β€” no in-memory state, same name, + * same cursor store β€” picks the persisted position up rather than + * replaying or skipping. + */ + public function testARestartedConsumerResumesWhereItLeftOff(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->consumer()->consume(fn (CloudEvent $event) => null); + + $this->producer->produce('c'); + + $this->assertSame(['c'], $this->drain($this->consumer())); + } + + public function testStopsAtTheFirstFailureAndLeavesThePositionBeforeIt(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + $this->producer->produce('c'); + + $consumer = $this->consumer(); + $seen = []; + + try { + $consumer->consume(function (CloudEvent $event) use (&$seen): void { + if ($event->type === 'b') { + throw new \RuntimeException('nope'); + } + + $seen[] = $event->type; + }); + $this->fail('The handler failure should have been re-raised'); + } catch (\RuntimeException $error) { + $this->assertSame('nope', $error->getMessage()); + } + + $this->assertSame(['a'], $seen); + $this->assertSame($first, $this->cursor->load($this->name, 'invalidator'), 'Progress before the failure is committed'); + } + + public function testRetriesTheFailedEventOnTheNextRun(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $consumer = $this->consumer(); + $attempts = 0; + + try { + $consumer->consume(function (CloudEvent $event) use (&$attempts): void { + if ($event->type === 'b') { + $attempts++; + throw new \RuntimeException('nope'); + } + }); + } catch (\RuntimeException) { + // Expected. + } + + $this->assertSame(['b'], $this->drain($consumer), 'The failed event comes back'); + $this->assertSame(1, $attempts); + } + + /** + * A failure on the very first event of a run commits nothing, so a store + * that was already empty stays empty rather than being written a position + * that stands for no completed work. + */ + public function testAFailureOnTheFirstEventCommitsNothing(): void + { + $this->producer->produce('a'); + + try { + $this->consumer()->consume(fn (CloudEvent $event) => throw new \RuntimeException('nope')); + } catch (\RuntimeException) { + // Expected. + } + + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } + + public function testAFailedEventBlocksTheOnesBehindItUntilItSucceeds(): void + { + $this->producer->produce('a'); + $this->producer->produce('poison'); + $this->producer->produce('c'); + + $consumer = $this->consumer(); + $seen = []; + $attempts = 0; + + // Fails the first time it sees the poison event and succeeds after, + // standing in for a dependency that was briefly unavailable. + $handler = function (CloudEvent $event) use (&$seen, &$attempts): void { + if ($event->type === 'poison') { + $attempts++; + + if ($attempts === 1) { + throw new \RuntimeException('not yet'); + } + } + + $seen[] = $event->type; + }; + + try { + $consumer->consume($handler); + $this->fail('The handler failure should have been re-raised'); + } catch (\RuntimeException) { + // Expected. + } + + $this->assertSame(['a'], $seen, 'Nothing behind the failure is delivered'); + + $consumer->consume($handler); + + $this->assertSame(['a', 'poison', 'c'], $seen, 'Order is preserved and nothing is skipped'); + $this->assertSame(2, $attempts, 'The failed event is retried, not dropped'); + } + + public function testDrainsABacklogInBatches(): void + { + foreach (\range(1, 10) as $i) { + $this->producer->produce('event-' . $i); + } + + $consumer = $this->consumer(batch: 4); + + $this->assertSame(4, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(4, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(2, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + } + + public function testTipStartDoesNotAnnounceTheBacklog(): void + { + $this->producer->produce('old-1'); + $this->producer->produce('old-2'); + + $consumer = $this->consumer('notifier', start: Consumer::START_TIP); + + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertNull($this->cursor->load($this->name, 'notifier'), 'Skipping the backlog is not progress to commit'); + } + + /** + * The tip is pinned when the poll starts, so an event landing while the + * poll waits is delivered β€” only the backlog is skipped. + */ + public function testTipStartDeliversWhatLandsMidPoll(): void + { + $store = new MidPollStore($this->name); + (new Producer($store, 'urn:test'))->produce('old'); + + $consumer = $this->consumer('notifier', timeout: 5_000, start: Consumer::START_TIP, store: $store); + + $seen = []; + $count = $consumer->consume(function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }); + + $this->assertSame(1, $count); + $this->assertSame(['landed'], $seen, 'The backlog is skipped; the mid-wait event is not'); + $this->assertNotNull($this->cursor->load($this->name, 'notifier'), 'Handling the event saves the position'); + } + + public function testTipStartOnAnEmptyFeedDeliversWhatLandsMidWait(): void + { + $consumer = $this->consumer('notifier', timeout: 5_000, start: Consumer::START_TIP, store: new MidPollStore($this->name)); + + $seen = []; + $consumer->consume(function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }); + + $this->assertSame(['landed'], $seen); + } + + public function testTipStartOnAnEmptyFeedWaitsOutTheTimeoutEmpty(): void + { + $consumer = $this->consumer('notifier', timeout: 600, start: Consumer::START_TIP); + + $started = \microtime(true); + + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertGreaterThanOrEqual(0.4, \microtime(true) - $started, 'Must actually wait'); + } + + public function testAStoredCursorBeatsTipStart(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->cursor->save($this->name, 'invalidator', $first); + + $consumer = $this->consumer(start: Consumer::START_TIP); + + $this->assertSame(['b'], $this->drain($consumer), 'A restart must not skip the gap'); + } + + public function testResetWithTipStartResumesFromNow(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + $this->cursor->save($this->name, 'invalidator', $first); + + $consumer = $this->consumer(start: Consumer::START_TIP); + + $this->assertSame(['b'], $this->drain($consumer), 'The stored position still wins before the reset'); + + $consumer->reset(); + + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null), 'After reset, the backlog is forgotten'); + } + + public function testResetReplaysEverythingStillRetained(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $consumer = $this->consumer(); + $consumer->consume(fn (CloudEvent $event) => null); + + $consumer->reset(); + + $this->assertNull($consumer->position()); + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + $this->assertSame(['a', 'b'], $this->drain($consumer)); + } + + public function testSeekPositionsTheNextRunStrictlyAfterTheGivenId(): void + { + $this->producer->produce('a'); + $second = $this->producer->produce('b'); + $this->producer->produce('c'); + + $consumer = $this->consumer(); + $consumer->seek($second); + + $this->assertSame($second, $consumer->position(), 'The seeked id is the position until something is handled'); + $this->assertSame(['c'], $this->drain($consumer)); + } + + /** + * A seek is persisted, not just remembered: a fresh Consumer sharing the + * cursor store and the name β€” a restart β€” resumes from it. + */ + public function testASeekSurvivesARestart(): void + { + $this->producer->produce('a'); + $second = $this->producer->produce('b'); + $this->producer->produce('c'); + + $this->consumer()->seek($second); + + $this->assertSame(['c'], $this->drain($this->consumer())); + } + + /** + * The operational escape hatch seek() exists for: a handler that keeps + * failing blocks the feed by design, and stepping past it is a deliberate + * seek to the failing event's own id. + */ + public function testSeekingToAPoisonEventsIdUnblocksTheConsumer(): void + { + $this->producer->produce('poison'); + $this->producer->produce('after'); + + $consumer = $this->consumer(); + $poison = null; + + $handler = function (CloudEvent $event) use (&$poison): void { + if ($event->type === 'poison') { + $poison = $event->id; + + throw new \RuntimeException('cannot handle this one'); + } + }; + + try { + $consumer->consume($handler); + $this->fail('The poison event should have blocked the run'); + } catch (\RuntimeException) { + // Expected: the feed is now blocked at the poison event. + } + + $this->assertNotNull($poison); + $consumer->seek($poison); + + $this->assertSame(['after'], $this->drain($consumer), 'The poison event is stepped over, nothing behind it is lost'); + } + + /** + * A hand-made move decided during a run is the newer decision, so the run + * must not save its own progress over it on the way out. + */ + public function testASeekMadeInsideAHandlerIsNotOverwritten(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + $third = $this->producer->produce('c'); + + $consumer = $this->consumer(batch: 1); + $consumer->consume(function (CloudEvent $event) use ($consumer, $third): void { + $consumer->seek($third); + }); + + $this->assertSame($third, $consumer->position()); + $this->assertSame($third, $this->cursor->load($this->name, 'invalidator')); + $this->assertSame([], $this->drain($consumer), 'The run resumes after the seeked id, not after the handled one'); + } + + public function testAResetMadeInsideAHandlerIsNotOverwritten(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $consumer = $this->consumer(); + $consumer->consume(function (CloudEvent $event) use ($consumer): void { + if ($event->type === 'b') { + $consumer->reset(); + } + }); + + $this->assertNull($consumer->position()); + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + $this->assertSame(['a', 'b'], $this->drain($consumer), 'The reset stands, so everything retained replays'); + } + + /** Whether the feed under test mints its own positions, and so judges their shape. */ + protected function ownsItsIdFormat(): bool + { + return true; + } + + /** A rejected seek must leave both positions exactly where they were. */ + private function assertSeekRejected(string $id): void + { + $first = $this->producer->produce('a'); + $this->cursor->save($this->name, 'invalidator', $first); + + $consumer = $this->consumer(); + + try { + $consumer->seek($id); + $this->fail('The id should have been rejected'); + } catch (Invalid) { + // Expected. + } + + $this->assertSame($first, $this->cursor->load($this->name, 'invalidator'), 'A rejected seek leaves the stored position untouched'); + $this->assertSame($first, $consumer->position()); + } + + #[DataProvider('noPositions')] + public function testSeekRejectsAnIdNoFeedCouldUse(string $id): void + { + $this->assertSeekRejected($id); + } + + /** + * Rejected whatever the feed is: an empty string names nothing, and the + * tip sentinel is a start rather than a position. + * + * @return array + */ + public static function noPositions(): array + { + return [ + 'empty' => [''], + 'the tip sentinel' => ['$'], + ]; + } + + #[DataProvider('notPositions')] + public function testSeekRejectsAnIdThatIsNotAPosition(string $id): void + { + if (!$this->ownsItsIdFormat()) { + $this->markTestSkipped('Only the feed that mints its positions can judge their shape'); + } + + $this->assertSeekRejected($id); + } + + /** + * @return array + */ + public static function notPositions(): array + { + return [ + 'not an id' => ['abc'], + 'too many parts' => ['1-2-3'], + ]; + } + + public function testConsumersOfTheSameFeedTrackSeparatePositions(): void + { + $this->producer->produce('a'); + + $one = $this->consumer('one'); + $two = $this->consumer('two'); + + $this->assertSame(1, $one->consume(fn (CloudEvent $event) => null)); + $this->assertSame(1, $two->consume(fn (CloudEvent $event) => null), 'The second consumer has its own position'); + $this->assertSame(0, $one->consume(fn (CloudEvent $event) => null)); + } + + /** + * A cursor store serves every feed a service consumes, so the feed's name + * is part of the key: the same consumer name on another feed is another + * position entirely. + */ + public function testTheSameConsumerNameOnAnotherFeedTracksItsOwnPosition(): void + { + $other = new MemoryStore($this->name . '-other'); + (new Producer($other, 'urn:test'))->produce('other-a'); + + $this->producer->produce('a'); + + $this->assertSame(['a'], $this->drain($this->consumer())); + $this->assertSame(['other-a'], $this->drain($this->consumer(store: $other)), 'Draining one feed must not advance the other'); + } + + /** + * The boundary the design draws rather than an accident: a name is one + * logical reader, so two processes behind one name split the feed instead + * of both seeing it. + */ + public function testTwoConsumersSharingANameSplitTheFeed(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $one = $this->consumer(batch: 1); + $two = $this->consumer(batch: 1); + + $this->assertSame(['a'], $this->drain($one)); + $this->assertSame(['b'], $this->drain($two), 'The second picks up after the first, it does not see a of its own'); + } + + /** + * And what sharing a name costs: a replica holding an older position + * re-handles events it polls (at-least-once), but its save is conditional + * on the position its run started from, so it cannot drag the shared + * position backwards β€” it concedes to the replica that got ahead. Every + * consumer still gets its own name; the fence bounds the damage, it does + * not split the feed cleanly. + */ + public function testAStaleConsumerSharingANameCannotDragThePositionBackwards(): void + { + $this->producer->produce('a'); + $second = $this->producer->produce('b'); + + $stale = $this->consumer(batch: 1); + $this->assertNull($stale->position(), 'Reads the shared position before the other replica moves it'); + + $ahead = $this->consumer(); + $this->assertSame(['a', 'b'], $this->drain($ahead)); + $this->assertSame($second, $this->cursor->load($this->name, 'invalidator')); + + $this->assertSame(['a'], $this->drain($stale), 'The stale replica re-handles from where it thought it was'); + $this->assertSame($second, $this->cursor->load($this->name, 'invalidator'), 'Its save is refused, so the shared position holds'); + } + + public function testPositionIsNullBeforeTheFirstRun(): void + { + $this->assertNull($this->consumer()->position()); + } + + public function testRejectsAnEmptyConsumerName(): void + { + $this->expectException(Invalid::class); + + $this->consumer(''); + } + + public function testExposesItsName(): void + { + $this->assertSame('invalidator', $this->consumer()->getName()); + } +} diff --git a/tests/Feed/Consumer/CacheTest.php b/tests/Feed/Consumer/CacheTest.php new file mode 100644 index 0000000..864a54c --- /dev/null +++ b/tests/Feed/Consumer/CacheTest.php @@ -0,0 +1,80 @@ +store($this->name); + + return [$store, new Producer($store, 'urn:test')]; + } + + public function testAConsumerDrainsACacheFedFeed(): void + { + [$store, $producer] = $this->fed(); + + $producer->produce('a'); + $last = $producer->produce('b'); + + $this->assertSame(['a', 'b'], $this->drain($this->consumer(store: $store), $count)); + $this->assertSame(2, $count); + $this->assertSame($last, $this->cursor->load($this->name, 'invalidator'), 'The position lives in the same cache as the feed'); + + $producer->produce('c'); + + // A fresh Consumer over the same cache: a restart. + $this->assertSame(['c'], $this->drain($this->consumer(store: $store)), 'Resumes without replaying'); + } + + /** A caught-up tick answers from the tip marker, so it must not get stuck there. */ + public function testACaughtUpConsumerStillSeesTheNextEvent(): void + { + [$store, $producer] = $this->fed(); + + $producer->produce('a'); + + $consumer = $this->consumer(store: $store); + + $this->assertSame(['a'], $this->drain($consumer)); + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null), 'Caught up'); + + $producer->produce('b'); + + $this->assertSame(['b'], $this->drain($consumer), 'And no longer'); + } + + public function testAConsumerPagesACacheFedBacklogInBatches(): void + { + [$store, $producer] = $this->fed(); + + foreach (\range(1, 10) as $i) { + $producer->produce('event-' . $i); + } + + $consumer = $this->consumer(batch: 4, store: $store); + + $this->assertSame(4, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(4, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(2, $consumer->consume(fn (CloudEvent $event) => null)); + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + } +} diff --git a/tests/Feed/Consumer/HttpTest.php b/tests/Feed/Consumer/HttpTest.php new file mode 100644 index 0000000..914681e --- /dev/null +++ b/tests/Feed/Consumer/HttpTest.php @@ -0,0 +1,191 @@ +endpoint = new FeedServer(new Server($store)); + } + + /** The feed belongs to another producer, which mints its own ids. */ + protected function ownsItsIdFormat(): bool + { + return false; + } + + /** + * http-feeds endpoints commonly use UUIDs, which `Remote` reads and + * `consume()` already saves β€” so refusing one here would take the + * poison-event escape hatch away from the consumers that need it most. + */ + public function testSeekAcceptsTheOpaqueIdARemoteFeedMayUse(): void + { + $opaque = '550e8400-e29b-41d4-a716-446655440000'; + + $consumer = $this->consumer(); + $consumer->seek($opaque); + + $this->assertSame($opaque, $consumer->position()); + $this->assertSame($opaque, $this->cursor->load($this->name, 'invalidator'), 'And it is persisted, so a restart resumes from it'); + } + + /** + * The feed name is the one thing on the wire only the serving side can + * check β€” the consumer's own `feed:` check is client-side. Pointed at the + * wrong feed it must fail, not read the right events by accident. + */ + public function testAConsumerPointedAtAnotherFeedIsNotServedThisOne(): void + { + $this->producer->produce('a'); + + $consumer = new Consumer($this->source($this->store), $this->cursor, 'invalidator', feed: $this->name . '-other'); + + try { + $consumer->consume(fn (CloudEvent $event) => null); + $this->fail('The endpoint holds another feed, so the read should have failed'); + } catch (Transport $error) { + $this->assertSame(404, $error->getCode()); + } + + $this->assertNull($consumer->position(), 'And nothing was recorded as read'); + } + + /** + * A name that needs encoding survives the round trip. Asserting the URI + * string, as the encoding test does, cannot show that anything decodes it. + */ + public function testAFeedNameThatNeedsEncodingStillRoutes(): void + { + $store = $this->store('a b/c'); + (new Producer($store, 'urn:test'))->produce('a'); + + $consumer = new Consumer($this->source($store), $this->cursor, 'invalidator', feed: 'a b/c'); + + $this->assertSame(['a'], $this->drain($consumer)); + $this->assertStringContainsString('a%20b%2Fc', $this->endpoint->recorder->last()['uri']); + } + + /** The two halves of the handshake checked against each other, not against a literal. */ + public function testTheAcceptSentIsTheContentTypeServed(): void + { + $this->producer->produce('a'); + + $this->drain($this->consumer()); + + $request = $this->endpoint->recorder->last(); + + $this->assertSame(Remote::MEDIA_TYPE, $request['headers']['Accept'] ?? null); + $this->assertSame(Remote::MEDIA_TYPE, $request['contentType'], 'The producer answers with what the consumer asked for'); + } + + /** + * One event through the real wire code both ways, every attribute + * asserted. Everything else round-trips through the store instead, so a + * drop on the wire specifically would pass the rest of the suite. + */ + public function testAnEventSurvivesTheWireWithEveryAttribute(): void + { + $id = $this->producer->publish(new CloudEvent( + id: '', + type: 'io.appwrite.edge.invalidate-rule', + source: 'ignored, the producer stamps its own', + subject: 'example.com', + time: '2026-07-31T09:15:02.123Z', + datacontenttype: 'application/json', + data: ['tags' => ['domain' => 'example.com'], 'depth' => [1, 2, 3]], + dataschema: 'https://example.com/schema.json', + extensions: ['traceparent' => '00-abc-def-01', 'retrycount' => 2, 'replayed' => true], + )); + + $received = null; + $this->consumer()->consume(function (CloudEvent $event) use (&$received): void { + $received = $event; + }); + + $this->assertInstanceOf(CloudEvent::class, $received); + $this->assertSame($id, $received->id); + $this->assertSame('1.0', $received->specversion); + $this->assertSame('io.appwrite.edge.invalidate-rule', $received->type); + $this->assertSame('urn:test', $received->source); + $this->assertSame('example.com', $received->subject); + $this->assertSame('2026-07-31T09:15:02.123Z', $received->time); + $this->assertSame('application/json', $received->datacontenttype); + $this->assertSame('https://example.com/schema.json', $received->dataschema); + $this->assertSame(['tags' => ['domain' => 'example.com'], 'depth' => [1, 2, 3]], $received->data); + $this->assertSame('00-abc-def-01', $received->extensions['traceparent']); + $this->assertSame(2, $received->extensions['retrycount']); + $this->assertTrue($received->extensions['replayed']); + } + + public function testTheProducerCachesFullBatchesAndNothingElse(): void + { + foreach (\range(1, 5) as $i) { + $this->producer->produce('event-' . $i); + } + + $consumer = $this->consumer(batch: 2); + + $consumer->consume(fn (CloudEvent $event) => null); + $consumer->consume(fn (CloudEvent $event) => null); + $consumer->consume(fn (CloudEvent $event) => null); + $consumer->consume(fn (CloudEvent $event) => null); + + $this->assertSame([ + 'private, max-age=31536000', // 2 of 2 β€” settled history + 'private, max-age=31536000', // 2 of 2 β€” settled history + 'no-store', // 1 of 2 β€” the live end, will grow + 'no-store', // 0 of 2 β€” caught up + ], $this->endpoint->recorder->cacheControl()); + } + + /** + * The tip sentinel crosses the wire as `lastEventId=$` and the producer + * resolves it inside the held request; once something is handled the + * position saves as a real id, so the sentinel never appears again. + */ + public function testTipStartSendsTheSentinelOnTheWireOnceOnly(): void + { + $store = new MidPollStore($this->name); + $producer = new Producer($store, 'urn:test'); + $producer->produce('old'); + + $consumer = $this->consumer('notifier', timeout: 5_000, start: Consumer::START_TIP, store: $store); + + $this->assertSame(['landed'], $this->drain($consumer), 'The backlog is skipped; the mid-wait event is not'); + $this->assertMatchesRegularExpression('/lastEventId=(%24|\$)/', $this->endpoint->recorder->last()['uri']); + + $producer->produce('after'); + + $this->assertSame(['after'], $this->drain($consumer)); + $this->assertDoesNotMatchRegularExpression('/lastEventId=(%24|\$)/', $this->endpoint->recorder->last()['uri']); + } +} diff --git a/tests/Feed/Consumer/MemoryTest.php b/tests/Feed/Consumer/MemoryTest.php new file mode 100644 index 0000000..bc8d3cc --- /dev/null +++ b/tests/Feed/Consumer/MemoryTest.php @@ -0,0 +1,12 @@ +producer->produce('a'); + $second = $this->producer->produce('b'); + + $consumer = $this->consumer(); + + $consumer->consume(fn (CloudEvent $event) => null); + $consumer->position(); + $consumer->seek($second); + $consumer->reset(); + + $this->assertSame(self::POOL_SIZE, $this->pool()->count(), 'The pool is whole again'); + } +} diff --git a/tests/Feed/Consumer/RedisTest.php b/tests/Feed/Consumer/RedisTest.php new file mode 100644 index 0000000..c85d3ec --- /dev/null +++ b/tests/Feed/Consumer/RedisTest.php @@ -0,0 +1,163 @@ +store($this->name); + + return [$store, new Producer($store, 'urn:test')]; + } + + public function testConsumesAStreamThroughAPersistedCursor(): void + { + [$store, $producer] = $this->stream(); + + $producer->produce('a'); + $last = $producer->produce('b'); + + $this->assertSame(['a', 'b'], $this->drain($this->consumer(store: $store))); + $this->assertSame($last, $this->cursor->load($this->name, 'invalidator')); + + $producer->produce('c'); + + // A fresh Consumer over the same cursor store: a restart. + $this->assertSame(['c'], $this->drain($this->consumer(store: $store)), 'Resumes without replaying'); + } + + /** + * `XADD` bumps the sequence within a millisecond, so a batch boundary + * regularly falls between two ids sharing a timestamp. + */ + public function testPagesAStreamInBatchesWithoutSkippingOrRepeating(): void + { + [$store, $producer] = $this->stream(); + + foreach (\range(1, 10) as $i) { + $producer->produce('event-' . $i); + } + + $consumer = $this->consumer(batch: 3, store: $store); + + $seen = []; + foreach (\range(1, 4) as $ignored) { + $seen = [...$seen, ...$this->drain($consumer)]; + } + + $this->assertSame(\array_map(static fn (int $i): string => 'event-' . $i, \range(1, 10)), $seen); + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null), 'And then it is caught up'); + } + + public function testASecondConsumerOfTheSameStreamGetsItsOwnPosition(): void + { + [$store, $producer] = $this->stream(); + + $producer->produce('a'); + + $this->assertSame(['a'], $this->drain($this->consumer('one', store: $store))); + $this->assertSame(['a'], $this->drain($this->consumer('two', store: $store)), 'The second reads it too'); + $this->assertSame([], $this->drain($this->consumer('one', store: $store)), 'The first stays caught up'); + } + + public function testResetReplaysWhatTheStreamStillRetains(): void + { + [$store, $producer] = $this->stream(); + + $producer->produce('a'); + $producer->produce('b'); + + $consumer = $this->consumer(store: $store); + $this->drain($consumer); + + $consumer->reset(); + + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + $this->assertSame(['a', 'b'], $this->drain($consumer)); + } + + /** + * The stored form is deliberately plain β€” the key is + * `feed::cursor:` and the value the event id as a string β€” + * so positions carry across upgrades and an operator can answer "where is + * this consumer?" from a shell. + */ + public function testThePositionIsStoredWhereOperatorsExpectIt(): void + { + $this->producer->produce('a'); + $last = $this->producer->produce('b'); + + $this->drain($this->consumer()); + + $this->assertSame($last, $this->redis()->get('feed:' . $this->name . ':cursor:invalidator')); + } + + /** + * The cursor's half of the `Transport` contract: `FailingCursor` raises + * one itself, so the wrapping here is otherwise never run. + * + * @param callable(Cursor): void $operation + */ + #[DataProvider('operations')] + public function testABackendThatCannotBeReachedRaisesTransport(callable $operation): void + { + $cursor = new RedisCursor(self::unreachableRedis()); + + $this->expectException(Transport::class); + + $operation($cursor); + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + 'load' => [static function (Cursor $cursor): void { + $cursor->load('edge', 'invalidator'); + }], + 'save' => [static function (Cursor $cursor): void { + $cursor->save('edge', 'invalidator', '1-0'); + }], + 'reset' => [static function (Cursor $cursor): void { + $cursor->reset('edge', 'invalidator'); + }], + ]; + } + + /** A position that cannot be read stops the run rather than replaying the feed. */ + public function testAConsumerOverAnUnreachableCursorStopsWithTransport(): void + { + $this->producer->produce('a'); + + $consumer = new Consumer($this->store, new RedisCursor(self::unreachableRedis()), 'invalidator', feed: $this->name); + + $this->expectException(Transport::class); + + $consumer->consume(fn (CloudEvent $event) => null); + } +} diff --git a/tests/Feed/Cursor/Base.php b/tests/Feed/Cursor/Base.php new file mode 100644 index 0000000..d0bcf38 --- /dev/null +++ b/tests/Feed/Cursor/Base.php @@ -0,0 +1,189 @@ +name = 'test-' . \bin2hex(\random_bytes(8)); + $this->cursor = $this->cursor(); + } + + public function testAConsumerThatHasNeverRunHasNoPosition(): void + { + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } + + public function testAPositionComesBackAsItWasSaved(): void + { + $this->cursor->save($this->name, 'invalidator', '1690000000000-7'); + + $this->assertSame('1690000000000-7', $this->cursor->load($this->name, 'invalidator')); + } + + /** + * Saving is how a consumer advances, so it happens on every run β€” the last + * one has to win rather than the first being kept. + */ + public function testSavingAgainMovesThePosition(): void + { + $this->cursor->save($this->name, 'invalidator', '1-0'); + $this->cursor->save($this->name, 'invalidator', '2-0'); + + $this->assertSame('2-0', $this->cursor->load($this->name, 'invalidator')); + } + + public function testConsumersOfOneFeedDoNotShareAPosition(): void + { + $this->cursor->save($this->name, 'one', '1-0'); + $this->cursor->save($this->name, 'two', '2-0'); + + $this->assertSame('1-0', $this->cursor->load($this->name, 'one')); + $this->assertSame('2-0', $this->cursor->load($this->name, 'two')); + } + + /** + * One cursor store serves every feed a service consumes, so the feed name + * is part of the key β€” the same consumer name on another feed is another + * position entirely. + */ + public function testTheSameConsumerNameOnAnotherFeedIsAnotherPosition(): void + { + $this->cursor->save($this->name, 'invalidator', '1-0'); + $this->cursor->save($this->name . '-other', 'invalidator', '2-0'); + + $this->assertSame('1-0', $this->cursor->load($this->name, 'invalidator')); + $this->assertSame('2-0', $this->cursor->load($this->name . '-other', 'invalidator')); + } + + public function testResetForgetsThePosition(): void + { + $this->cursor->save($this->name, 'invalidator', '1-0'); + $this->cursor->reset($this->name, 'invalidator'); + + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } + + /** Resetting a consumer that never saved anything is the ordinary case. */ + public function testResettingAPositionThatWasNeverSavedIsHarmless(): void + { + $this->cursor->reset($this->name, 'invalidator'); + + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } + + public function testResetOnlyForgetsTheConsumerItNames(): void + { + $this->cursor->save($this->name, 'one', '1-0'); + $this->cursor->save($this->name, 'two', '2-0'); + + $this->cursor->reset($this->name, 'one'); + + $this->assertNull($this->cursor->load($this->name, 'one')); + $this->assertSame('2-0', $this->cursor->load($this->name, 'two'), 'The other consumer is untouched'); + } + + public function testAdvanceLandsWhileThePositionIsUnmoved(): void + { + $this->assertTrue($this->cursor->advance($this->name, 'invalidator', '1-0', null)); + $this->assertSame('1-0', $this->cursor->load($this->name, 'invalidator')); + + $this->assertTrue($this->cursor->advance($this->name, 'invalidator', '2-0', '1-0')); + $this->assertSame('2-0', $this->cursor->load($this->name, 'invalidator')); + } + + /** A save conditioned on a position that has since moved is refused: the newer decision stands. */ + public function testAdvanceIsRefusedWhenThePositionHasMoved(): void + { + $this->cursor->save($this->name, 'invalidator', '5-0'); + + $this->assertFalse($this->cursor->advance($this->name, 'invalidator', '3-0', '2-0')); + $this->assertFalse($this->cursor->advance($this->name, 'invalidator', '3-0', null), 'A run that started from nothing concedes too'); + $this->assertSame('5-0', $this->cursor->load($this->name, 'invalidator')); + } + + public function testAdvanceIsRefusedAfterAReset(): void + { + $this->cursor->save($this->name, 'invalidator', '5-0'); + $this->cursor->reset($this->name, 'invalidator'); + + $this->assertFalse($this->cursor->advance($this->name, 'invalidator', '6-0', '5-0')); + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } + + /** + * Refused by every operation, not just the first one a caller reaches β€” + * which is what says the adapter goes through the shared key builder. + * + * @param callable(Cursor, string, string): void $operation + */ + #[DataProvider('operationsAndNames')] + public function testEveryOperationRefusesAnUnusableName(callable $operation, string $feed, string $consumer): void + { + $this->expectException(Invalid::class); + + $operation($this->cursor, $feed, $consumer); + } + + /** + * @return array + */ + public static function operationsAndNames(): array + { + $operations = [ + 'load' => static function (Cursor $cursor, string $feed, string $consumer): void { + $cursor->load($feed, $consumer); + }, + 'save' => static function (Cursor $cursor, string $feed, string $consumer): void { + $cursor->save($feed, $consumer, '1-0'); + }, + 'reset' => static function (Cursor $cursor, string $feed, string $consumer): void { + $cursor->reset($feed, $consumer); + }, + 'advance' => static function (Cursor $cursor, string $feed, string $consumer): void { + $cursor->advance($feed, $consumer, '1-0', null); + }, + ]; + + $names = [ + 'no feed' => ['', 'invalidator'], + 'no consumer' => ['edge', ''], + 'neither' => ['', ''], + ]; + + $cases = []; + + foreach ($operations as $operation => $callable) { + foreach ($names as $name => [$feed, $consumer]) { + $cases["{$operation} with {$name}"] = [$callable, $feed, $consumer]; + } + } + + return $cases; + } +} diff --git a/tests/Feed/Cursor/CacheTest.php b/tests/Feed/Cursor/CacheTest.php new file mode 100644 index 0000000..bb1df17 --- /dev/null +++ b/tests/Feed/Cursor/CacheTest.php @@ -0,0 +1,12 @@ +redis()->xAdd(Key::cursor($this->name, 'invalidator'), '1690000000000-7', ['p' => '1']); + + $this->assertSame('1690000000000-7', $this->cursor->load($this->name, 'invalidator')); + } + + /** The first save after the upgrade converts the key to a string in place. */ + public function testSavingOverAStreamPositionConvertsTheKey(): void + { + $key = Key::cursor($this->name, 'invalidator'); + $this->redis()->xAdd($key, '1-0', ['p' => '1']); + + $this->cursor->save($this->name, 'invalidator', '2-0'); + + $this->assertSame(\Redis::REDIS_STRING, $this->redis()->type($key)); + $this->assertSame('2-0', $this->cursor->load($this->name, 'invalidator')); + } + + /** advance() compares against a legacy stream position, and a landing one converts the key. */ + public function testAdvanceOverAStreamPositionComparesAndConverts(): void + { + $key = Key::cursor($this->name, 'invalidator'); + $this->redis()->xAdd($key, '1-0', ['p' => '1']); + + $this->assertFalse($this->cursor->advance($this->name, 'invalidator', '9-0', '0-5'), 'A stale expectation is refused against a stream too'); + + $this->assertTrue($this->cursor->advance($this->name, 'invalidator', '2-0', '1-0')); + $this->assertSame(\Redis::REDIS_STRING, $this->redis()->type($key)); + $this->assertSame('2-0', $this->cursor->load($this->name, 'invalidator')); + } + + public function testResetForgetsAPositionRetainedAsAStream(): void + { + $this->redis()->xAdd(Key::cursor($this->name, 'invalidator'), '1-0', ['p' => '1']); + + $this->cursor->reset($this->name, 'invalidator'); + + $this->assertNull($this->cursor->load($this->name, 'invalidator')); + } +} diff --git a/tests/Feed/Producer/Base.php b/tests/Feed/Producer/Base.php new file mode 100644 index 0000000..5d18216 --- /dev/null +++ b/tests/Feed/Producer/Base.php @@ -0,0 +1,425 @@ +name = 'test-' . \bin2hex(\random_bytes(8)); + $this->store = $this->store($this->name); + $this->producer = new Producer($this->store, 'urn:test'); + } + + /** @return list */ + protected function events(): array + { + return $this->store->read(null, 1000); + } + + public function testProduceReturnsAPosition(): void + { + $id = $this->producer->produce('io.appwrite.edge.invalidate', ['tags' => ['project' => 'p1']]); + + $this->assertTrue(Id::isValid($id), "The backend returned an id this library cannot page from: {$id}"); + } + + public function testRoundTripsAnEvent(): void + { + $id = $this->producer->produce('io.appwrite.edge.invalidate-rule', ['tags' => ['domain' => 'example.com']], 'example.com'); + + $events = $this->events(); + + $this->assertCount(1, $events); + $this->assertSame($id, $events[0]->id); + $this->assertSame('io.appwrite.edge.invalidate-rule', $events[0]->type); + $this->assertSame('example.com', $events[0]->subject); + $this->assertSame('urn:test', $events[0]->source); + $this->assertSame(['tags' => ['domain' => 'example.com']], $events[0]->data); + $this->assertNotNull($events[0]->time); + $this->assertSame('application/json', $events[0]->datacontenttype, 'produce() encodes its payload as JSON and says so'); + } + + public function testStampsTheSourceAndTime(): void + { + $this->producer->produce('test'); + + $event = $this->events()[0]; + + $this->assertSame('urn:test', $event->source); + $this->assertNotNull($event->time); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $event->time); + } + + /** + * Recording it when produced rather than when read keeps it correct for a + * feed read back somewhere other than where it was written. + */ + public function testKeepsTheSourceOfTheProducerThatProduced(): void + { + (new Producer($this->store, 'urn:appwrite:cloud:fra'))->produce('test'); + (new Producer($this->store, 'urn:appwrite:cloud:nyc'))->produce('test'); + + $events = $this->events(); + + $this->assertSame('urn:appwrite:cloud:fra', $events[0]->source); + $this->assertSame('urn:appwrite:cloud:nyc', $events[1]->source); + } + + public function testPublishStampsAPreparedEvent(): void + { + $id = $this->producer->publish(new CloudEvent(id: 'ignored', type: 'test', source: 'ignored', data: ['a' => 'b'], subject: 's')); + + $event = $this->events()[0]; + + $this->assertSame($id, $event->id); + $this->assertNotSame('ignored', $event->id, 'The backend assigns the position, not the caller'); + $this->assertSame('urn:test', $event->source); + $this->assertSame(['a' => 'b'], $event->data); + } + + /** + * A producer can only speak for itself, so an event relayed from another + * feed is republished as this service's β€” surprising enough to pin. + */ + public function testPublishReplacesTheCallersSourceWithTheProducersOwn(): void + { + $this->producer->publish(new CloudEvent(id: '', type: 'test', source: 'urn:somebody:else')); + + $this->assertSame('urn:test', $this->events()[0]->source); + } + + /** Another version would leave an entry in the feed that nothing can read. */ + public function testPublishNormalisesTheSpecVersion(): void + { + $this->producer->publish(new CloudEvent(id: '', type: 'test', source: '', specversion: '1.1')); + + $this->assertSame('1.0', $this->events()[0]->specversion); + } + + public function testPublishKeepsATimeTheCallerSet(): void + { + $this->producer->publish(new CloudEvent(id: '', type: 'test', source: '', time: '2020-01-01T00:00:00.000Z')); + + $this->assertSame('2020-01-01T00:00:00.000Z', $this->events()[0]->time); + } + + public function testEventsComeBackOldestFirst(): void + { + foreach (['a', 'b', 'c'] as $type) { + $this->producer->produce($type); + } + + $this->assertSame(['a', 'b', 'c'], \array_map(fn (CloudEvent $e): string => $e->type, $this->events())); + } + + public function testIdsAreStrictlyIncreasingEvenWithinAMillisecond(): void + { + $ids = []; + for ($i = 0; $i < 50; $i++) { + $ids[] = $this->producer->produce('test'); + } + + $this->assertSame($ids, \array_unique($ids), 'Positions must be unique'); + + for ($i = 1; $i < \count($ids); $i++) { + $this->assertGreaterThan(Id::decode($ids[$i - 1]), Id::decode($ids[$i]), 'Positions must increase'); + } + } + + /** + * @return array + */ + public static function payloads(): array + { + return [ + 'map' => [['tags' => ['domain' => 'example.com']]], + 'list' => [['a', 'b', 'c']], + 'nested list' => [[['x' => 1], ['x' => 2]]], + 'string' => ['a string'], + 'number' => [42], + 'float' => [1.5], + 'boolean' => [true], + 'null' => [null], + 'empty' => [[]], + 'unicode' => [['unicode' => 'ΓΌnΓ―cΓΈde βœ“']], + ]; + } + + /** + * The JSON event format leaves `data` unrestricted, so a list or a scalar + * has to survive as itself β€” a list must not come back as a map. + */ + #[DataProvider('payloads')] + public function testAnyJsonPayloadSurvivesTheRoundTrip(mixed $data): void + { + $this->producer->produce('test', $data); + + $this->assertSame($data, $this->events()[0]->data); + } + + /** + * A producer that attaches a `traceparent` means it to reach the consumer. + * Stamping the event on publish rebuilds it, and storing it flattens it, so + * either step could quietly drop an attribute this library does not model. + */ + public function testExtensionAttributesSurviveAppendAndRead(): void + { + $this->producer->publish(new CloudEvent( + id: '', + type: 'test', + source: '', + extensions: ['traceparent' => '00-abc-def-01', 'retrycount' => 2], + )); + + $event = $this->events()[0]; + + $this->assertSame('00-abc-def-01', $event->extensions['traceparent']); + $this->assertSame(2, $event->extensions['retrycount']); + } + + /** + * An extension name of only digits is legal β€” the spec allows `[a-z0-9]+` β€” + * and PHP stores such a name as an integer key. Anything that merges the + * extensions back in with a spread, or with `array_merge()`, renumbers that + * key and silently loses the attribute. + */ + public function testADigitsOnlyExtensionNameSurvivesAppendAndRead(): void + { + $this->producer->publish(new CloudEvent( + id: '', + type: 'test', + source: '', + // @phpstan-ignore argument.type ('123' is an integer key in PHP) + extensions: ['123' => 'digits', 'trace' => 'ok'], + )); + + $event = $this->events()[0]; + + // @phpstan-ignore offsetAccess.notFound + $this->assertSame('digits', $event->extensions['123']); + $this->assertSame('ok', $event->extensions['trace']); + } + + /** + * Losing `datacontenttype` leaves a consumer holding data it can no longer + * interpret, and only a round trip can show the attribute made it through. + */ + public function testDatacontenttypeSurvivesAppendAndRead(): void + { + $this->producer->publish(new CloudEvent( + id: '', + type: 'test', + source: '', + datacontenttype: 'application/xml', + data: '', + )); + + $event = $this->events()[0]; + + $this->assertSame('application/xml', $event->datacontenttype); + $this->assertSame('', $event->data); + } + + /** Absent means JSON, so it must not come back as the empty string the store flattens it to. */ + public function testAnEventWithNoDatacontenttypeReadsBackWithNone(): void + { + $this->producer->publish(new CloudEvent(id: '', type: 'test', source: '', datacontenttype: null)); + + $this->assertNull($this->events()[0]->datacontenttype); + } + + public function testDataschemaSurvivesAppendAndRead(): void + { + $this->producer->publish(new CloudEvent( + id: '', + type: 'test', + source: '', + dataschema: 'https://example.com/schema.json', + )); + + $this->assertSame('https://example.com/schema.json', $this->events()[0]->dataschema); + } + + /** + * CloudEvents models an absent subject as null rather than an empty string, + * so a caller checking for one must check for null. + */ + public function testAnEventWithNoSubjectHasANullSubject(): void + { + $this->producer->produce('test'); + + $this->assertNull($this->events()[0]->subject); + } + + public function testASubjectSurvivesAppendAndRead(): void + { + $this->producer->produce('test', [], 'example.com'); + + $this->assertSame('example.com', $this->events()[0]->subject); + } + + public function testRejectsAnEmptyEventType(): void + { + $this->expectException(Invalid::class); + + $this->producer->produce(''); + } + + public function testRejectsAPayloadThatCannotBeEncoded(): void + { + $this->expectException(Invalid::class); + + $this->producer->produce('test', ['resource' => \fopen('php://memory', 'r')]); + } + + /** + * CloudEvents requires a source, and an event stamped with an empty one is + * an event no consumer can attribute. + */ + public function testRejectsAnEmptySource(): void + { + $this->expectException(Invalid::class); + + new Producer($this->store, ''); + } + + /** + * Trimming may be approximate (Redis trims to node boundaries), so this + * asserts the property every adapter owes rather than an exact count: the + * feed stays bounded, the oldest events go first, and the newest survives. + */ + public function testRetentionIsBoundedAndDropsTheOldestFirst(): void + { + $store = $this->store($this->name, maxSize: 10); + $producer = new Producer($store, 'urn:test'); + + $producer->produce('first'); + + foreach (\range(1, 299) as $i) { + $producer->produce('event-' . $i); + } + + $types = \array_map(fn (CloudEvent $e): string => $e->type, $store->read(null, 1000)); + + $this->assertLessThan(300, \count($types), 'The feed must be trimmed'); + $this->assertNotContains('first', $types, 'The oldest event goes first'); + $this->assertSame('event-299', \end($types), 'The newest event is retained'); + } + + /** Whether this adapter trims to exactly the cap; Redis trims to a node boundary. */ + protected function trimsExactly(): bool + { + return true; + } + + /** An adapter that trims exactly owes the tighter contract: the bound is the cap itself. */ + public function testRetentionTrimsToExactlyTheCap(): void + { + if (!$this->trimsExactly()) { + $this->markTestSkipped('This adapter trims approximately'); + } + + $store = $this->store($this->name, maxSize: 3); + $producer = new Producer($store, 'urn:test'); + + foreach (['a', 'b', 'c', 'd', 'e'] as $type) { + $producer->produce($type); + } + + $this->assertSame(['c', 'd', 'e'], \array_map(fn (CloudEvent $e): string => $e->type, $store->read(null, 10))); + } + + public function testAcceptsTheSmallestUsefulRetentionCap(): void + { + if (!$this->trimsExactly()) { + $this->markTestSkipped('This adapter trims approximately'); + } + + $store = $this->store($this->name, maxSize: 1); + $producer = new Producer($store, 'urn:test'); + + $producer->produce('a'); + $producer->produce('b'); + + $events = $store->read(null, 10); + + $this->assertCount(1, $events); + $this->assertSame('b', $events[0]->type); + } + + public function testRejectsAnEmptyFeedName(): void + { + $this->expectException(Invalid::class); + + $this->store(''); + } + + #[DataProvider('notRetentions')] + public function testRejectsARetentionThatKeepsNothing(int $maxSize): void + { + $this->expectException(Invalid::class); + + $this->store($this->name, maxSize: $maxSize); + } + + /** + * @return array + */ + public static function notRetentions(): array + { + return [ + 'zero' => [0], + 'negative' => [-5], + ]; + } + + #[DataProvider('notIntervals')] + public function testRejectsAPollIntervalBelowAMillisecond(int $pollInterval): void + { + $this->expectException(Invalid::class); + + $this->store($this->name, pollInterval: $pollInterval); + } + + /** + * @return array + */ + public static function notIntervals(): array + { + return [ + 'zero' => [0], + 'negative' => [-5], + ]; + } +} diff --git a/tests/Feed/Producer/CacheTest.php b/tests/Feed/Producer/CacheTest.php new file mode 100644 index 0000000..823c614 --- /dev/null +++ b/tests/Feed/Producer/CacheTest.php @@ -0,0 +1,166 @@ +producer->produce('a'); + + $events = (new CacheStore($this->cache(), $this->name))->read(null, 10); + + $this->assertCount(1, $events); + $this->assertSame($id, $events[0]->id); + } + + /** + * A cache is allowed to forget, and it may also hold a foreign value under + * the feed's key. Both read as an empty feed β€” a replay, never a fault. + */ + public function testAForeignValueUnderTheFeedsKeyReadsAsEmpty(): void + { + $this->cache()->save('feed:' . $this->name, ['not' => 'a feed']); + + $this->assertCount(0, $this->store->read(null, 10)); + $this->assertNull($this->store->tip()); + } + + /** + * The whole feed lives under one key, so answering "anything new?" by + * reading it costs the entire feed β€” per tick, per waiting consumer. + */ + public function testACaughtUpPollReadsTheTipMarkerRatherThanTheFeed(): void + { + $adapter = new CountingCache(); + $store = new CacheStore(new UtopiaCache($adapter), $this->name, pollInterval: 50); + + $last = (new Producer($store, 'urn:test'))->produce('a'); + $adapter->forget(); + + $events = (new Server($store))->poll($last, 10, 400); + + $this->assertCount(0, $events, 'The consumer is caught up, so the poll waits out its timeout'); + $this->assertGreaterThan(2, $adapter->reads(Key::tip($this->name)), 'Each tick checks the marker'); + $this->assertSame(0, $adapter->reads(Key::feed($this->name)), 'And never loads the feed to learn nothing'); + } + + /** + * A cache may drop one key and keep another, so a missing marker must fall + * through to the real read rather than strand the consumer. + */ + public function testAMissingTipMarkerFallsBackToReadingTheFeed(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->cache()->purge(Key::tip($this->name)); + + $this->assertCount(1, $this->store->read($first, 10)); + } + + /** And the gate must not be sticky, or a quiet feed stays quiet forever. */ + public function testAnEventAppendedAfterACaughtUpReadIsStillDelivered(): void + { + $first = $this->producer->produce('a'); + + $this->assertCount(0, $this->store->read($first, 10), 'Caught up'); + + $this->producer->produce('b'); + + $this->assertCount(1, $this->store->read($first, 10), 'And no longer'); + } + + /** Retention is also the size of every append here, so the default is far smaller. */ + public function testTheDefaultRetentionIsScaledToWhatAnAppendCosts(): void + { + $store = new CacheStore($this->cache(), $this->name); + $producer = new Producer($store, 'urn:test'); + + foreach (\range(1, 1_001) as $i) { + $producer->produce('event-' . $i); + } + + $events = $store->read(null, 2_000); + + $this->assertCount(1_000, $events); + $this->assertSame('event-2', $events[0]->type, 'The oldest went first'); + } + + /** A store over a cache backend that is down, as an operator would meet it. */ + private function unreachable(): Store&Appendable + { + return new CacheStore(new UtopiaCache(new BrokenCache(raises: true)), $this->name); + } + + /** + * A cache lets the backend's own error out once its retries are exhausted. + * Unwrapped, the canonical consume loop crashes on a backend blip instead + * of retrying β€” what the Transport contract exists to prevent. + * + * @param callable(Store&Appendable): void $operation + */ + #[DataProvider('operations')] + public function testABackendThatIsDownRaisesTransport(callable $operation): void + { + $store = $this->unreachable(); + + $this->expectException(Transport::class); + + $operation($store); + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + 'read' => [static function (Store&Appendable $store): void { + $store->read(null, 10); + }], + 'tip' => [static function (Store&Appendable $store): void { + $store->tip(); + }], + 'append' => [static function (Store&Appendable $store): void { + $store->append(new CloudEvent(id: '', type: 'test', source: 'urn:test')); + }], + ]; + } + + /** + * A write answered with false must be caught too, or the position reported + * names an event no consumer can ever read. + */ + public function testACacheThatRejectsTheWriteRaisesTransport(): void + { + $store = new CacheStore(new UtopiaCache(new BrokenCache()), $this->name); + + $this->expectException(Transport::class); + + $store->append(new CloudEvent(id: '', type: 'test', source: 'urn:test')); + } +} diff --git a/tests/Feed/Producer/MemoryTest.php b/tests/Feed/Producer/MemoryTest.php new file mode 100644 index 0000000..482a4c6 --- /dev/null +++ b/tests/Feed/Producer/MemoryTest.php @@ -0,0 +1,12 @@ +producer->produce('a'); + $this->producer->produce('b'); + + $this->assertSame(2, $this->redis()->xLen('feed:' . $this->name)); + } + + /** + * Feeds and cursors share one keyspace. Joined raw, a feed named + * `:cursor:x` takes the key consumer `x`'s position occupies, and + * the cursor `SET` lands on β€” and destroys β€” the stream. + */ + public function testAFeedNamedLikeACursorKeyDoesNotCollideWithOne(): void + { + $store = $this->store($this->name . ':cursor:x'); + (new Producer($store, 'urn:test'))->produce('a'); + + $cursor = $this->cursor(); + $cursor->save($this->name, 'x', '1-0'); + + $this->assertSame('1-0', $cursor->load($this->name, 'x'), 'The position is readable back'); + $this->assertCount(1, $store->read(null, 10), 'And the feed still holds its event'); + } + + /** Trimming must happen on the server, not only in what read() returns. */ + public function testTheStreamItselfIsTrimmed(): void + { + $store = $this->store($this->name, maxSize: 10); + $producer = new Producer($store, 'urn:test'); + + foreach (\range(1, 300) as $i) { + $producer->produce('event-' . $i); + } + + $this->assertLessThan(300, $this->redis()->xLen('feed:' . $this->name), 'The stream must be trimmed'); + } + + /** + * Any tool can `XADD` into a stream, so an entry carrying an attribute a + * CloudEvent cannot hold must decode without it β€” as over HTTP. A read + * decodes every entry, so otherwise it fails every read past it forever. + */ + public function testAForeignWritersUnusableExtensionIsDroppedRatherThanWedgingTheFeed(): void + { + $this->producer->produce('a'); + + $this->redis()->xAdd(Key::feed($this->name), '*', [ + 'type' => 'foreign', + 'source' => 'urn:somebody:else', + 'subject' => '', + 'datacontenttype' => '', + 'dataschema' => '', + 'time' => '', + 'data' => '{"ok":true}', + 'extensions' => '{"ratio":1.5,"trace":"abc"}', + ]); + + $this->producer->produce('c'); + + $events = $this->store->read(null, 10); + + $this->assertSame(['a', 'foreign', 'c'], \array_map(fn (CloudEvent $e): string => $e->type, $events), 'Nothing behind it is lost'); + $this->assertSame(['trace' => 'abc'], $events[1]->extensions, 'Only the attribute it could not hold is gone'); + } + + /** + * The README promises `Transport` for a Redis failure. Untested, a raw + * `\RedisException` escaping would crash consumers catching + * `Utopia\Feed\Exception` as documented. + * + * @param callable(Store&Appendable): void $operation + */ + #[DataProvider('operations')] + public function testABackendThatCannotBeReachedRaisesTransport(callable $operation): void + { + $store = new RedisStore(self::unreachableRedis(), $this->name); + + $this->expectException(Transport::class); + + $operation($store); + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + 'read' => [static function (Store&Appendable $store): void { + $store->read(null, 10); + }], + 'tip' => [static function (Store&Appendable $store): void { + $store->tip(); + }], + 'append' => [static function (Store&Appendable $store): void { + $store->append(new CloudEvent(id: '', type: 'test', source: 'urn:test')); + }], + ]; + } + + /** + * A foreign value under the feed's key makes Redis answer stream commands + * with an error rather than raising. Appending must not report a position + * for an event that is not in the feed, so the reply is checked. + */ + public function testAppendingOverAForeignValueRaisesTransport(): void + { + $this->redis()->set(Key::feed($this->name), 'not a stream'); + + $this->expectException(Transport::class); + + $this->store->append(new CloudEvent(id: '', type: 'test', source: 'urn:test')); + } + + /** + * Reading is the opposite call: a feed nobody can read is an empty one β€” a + * replay at worst β€” where failing would stall every consumer of it. + */ + public function testReadingPastAForeignValueIsAnEmptyFeed(): void + { + $this->redis()->set(Key::feed($this->name), 'not a stream'); + + $this->assertSame([], $this->store->read(null, 10)); + $this->assertNull($this->store->tip()); + } +} diff --git a/tests/Feed/Server/Base.php b/tests/Feed/Server/Base.php new file mode 100644 index 0000000..89ccc31 --- /dev/null +++ b/tests/Feed/Server/Base.php @@ -0,0 +1,376 @@ +name = 'test-' . \bin2hex(\random_bytes(8)); + $this->store = $this->store($this->name); + $this->producer = new Producer($this->store, 'urn:test'); + $this->server = new Server($this->store); + } + + /** @return list */ + protected static function events(Batch $batch): array + { + return \array_values(\iterator_to_array($batch)); + } + + /** @return list */ + protected static function types(Batch $batch): array + { + return \array_map(fn (CloudEvent $e): string => $e->type, self::events($batch)); + } + + public function testReadsBackWhatWasAppended(): void + { + $id = $this->producer->produce('io.appwrite.edge.invalidate-rule', ['tags' => ['domain' => 'example.com']], 'example.com'); + + $events = self::events($this->server->read()); + + $this->assertCount(1, $events); + $this->assertSame($id, $events[0]->id); + $this->assertSame('io.appwrite.edge.invalidate-rule', $events[0]->type); + $this->assertSame(['tags' => ['domain' => 'example.com']], $events[0]->data); + } + + /** + * The reason positions advance arithmetically instead of with Redis' + * `(`-exclusive range syntax: "strictly after" has to hold on every + * backend, not only the ones whose range API can express it. + */ + public function testReadsStrictlyAfterTheGivenPosition(): void + { + $first = $this->producer->produce('a'); + $second = $this->producer->produce('b'); + + $events = self::events($this->server->read($first)); + + $this->assertCount(1, $events); + $this->assertSame($second, $events[0]->id); + } + + public function testReadFromTheLastEventIsEmpty(): void + { + $this->producer->produce('a'); + $last = $this->producer->produce('b'); + + $this->assertCount(0, $this->server->read($last)); + } + + public function testNullPositionReadsFromTheOldestRetainedEvent(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertSame(['a', 'b'], self::types($this->server->read(null))); + } + + public function testHonoursTheLimit(): void + { + foreach (\range(1, 10) as $i) { + $this->producer->produce('test'); + } + + $this->assertCount(3, $this->server->read(null, 3)); + } + + /** + * `limit` arrives from a consumer, so it is clamped rather than rejected β€” + * failing the read would stall a feed over something the producer can just + * decide. + */ + public function testClampsTheLimitToTheMaximum(): void + { + $this->producer->produce('test'); + + $this->assertCount(1, $this->server->read(null, Readable::MAX_BATCH * 10)); + $this->assertCount(1, $this->server->read(null, 0)); + $this->assertCount(1, $this->server->read(null, -5)); + } + + public function testRejectsAPositionThatIsNotAFeedId(): void + { + $this->expectException(Invalid::class); + + $this->server->read('not-a-position'); + } + + /** + * Every upper bound below is set from the elapsed time the *regression* + * would produce, not from what the correct code takes β€” a tighter bound + * measures the CI runner. Lower bounds cannot flake: a sleep cannot end + * early. + */ + public function testPollReturnsImmediatelyWhenEventsAreWaiting(): void + { + $this->producer->produce('test'); + + $started = \microtime(true); + $events = $this->server->poll(null, 10, 2000); + + $this->assertCount(1, $events); + // Waiting out the 2s timeout despite a full batch is the regression. + $this->assertLessThan(1, \microtime(true) - $started, 'Waiting events must return at once, not at the timeout'); + } + + public function testPollGivesUpAtTheTimeoutWithAnEmptyBatch(): void + { + $started = \microtime(true); + $events = $this->server->poll(null, 10, 600); + $elapsed = \microtime(true) - $started; + + $this->assertCount(0, $events); + $this->assertGreaterThanOrEqual(0.4, $elapsed, 'Must actually wait'); + $this->assertLessThan(3.0, $elapsed, 'Must not wait far past the timeout'); + } + + public function testPollWithoutATimeoutIsAPlainRead(): void + { + $started = \microtime(true); + + $this->assertCount(0, $this->server->poll()); + // Sleeping at all with no timeout costs a whole 500ms interval. + $this->assertLessThan(0.45, \microtime(true) - $started, 'No timeout means no sleep'); + } + + /** + * The overshoot fix: the poll loop must sleep the remaining time when that + * is less than the interval, not a full interval past the deadline. + */ + public function testPollHonoursATimeoutShorterThanThePollInterval(): void + { + $server = new Server($this->store($this->name, pollInterval: 500)); + + $started = \microtime(true); + $events = $server->poll(null, 10, 100); + $elapsed = \microtime(true) - $started; + + $this->assertCount(0, $events); + $this->assertGreaterThanOrEqual(0.08, $elapsed, 'Must actually wait out the timeout'); + // Overshooting sleeps the full 500ms interval past a 100ms deadline. + $this->assertLessThan(0.45, $elapsed, 'Must not sleep a full interval past the deadline'); + } + + public function testTipIsTheNewestEventsId(): void + { + $this->assertNull($this->server->tip(), 'An empty feed has no tip'); + + $this->producer->produce('a'); + $last = $this->producer->produce('b'); + + $this->assertSame($last, $this->server->tip()); + } + + public function testReadingFromTheTipSentinelSkipsTheBacklog(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertCount(0, $this->server->read(Readable::TIP)); + } + + /** + * The one case a caller has to design for: a consumer that fell behind the + * trim horizon gets what is left, not an error and not a gap it can detect. + */ + public function testAPositionBelowTheTrimHorizonReadsWhatIsLeft(): void + { + $store = $this->store($this->name, maxSize: 10); + $producer = new Producer($store, 'urn:test'); + $server = new Server($store); + + $first = $producer->produce('first'); + + foreach (\range(1, 300) as $i) { + $producer->produce('event-' . $i); + } + + $events = $server->read($first); + + $this->assertFalse($events->isEmpty(), 'A consumer that fell behind must still get what is retained'); + $this->assertLessThan(300, \count($events), 'The feed must be trimmed'); + } + + public function testExposesTheFeedItReads(): void + { + $this->assertSame($this->name, $this->server->getName()); + } + + public function testServeAppliesTheDefaultsWhenNoParametersArrive(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertCount(2, $this->server->serve([])); + } + + public function testServeCoercesTheStringValuesARouteHands(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + $this->producer->produce('c'); + + $batch = $this->server->serve([ + 'lastEventId' => $first, + 'limit' => '1', + 'timeout' => '0', + ]); + + $this->assertSame(['b'], self::types($batch)); + } + + public function testServeTreatsAnEmptyLastEventIdAsAbsent(): void + { + $this->producer->produce('a'); + + $this->assertCount(1, $this->server->serve(['lastEventId' => ''])); + } + + public function testServeRejectsALastEventIdThatIsNotAPosition(): void + { + $this->expectException(Invalid::class); + + $this->server->serve(['lastEventId' => 'not-a-position']); + } + + /** + * PHP parses `?lastEventId[]=1-0` into an array, so a present + * `lastEventId` need not be a string. Coercing one to "absent" answers a + * malformed parameter with a full replay of the feed instead of a 400. + */ + #[DataProvider('notStrings')] + public function testServeRejectsALastEventIdThatIsNotEvenAString(mixed $lastEventId): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->expectException(Invalid::class); + + $this->server->serve(['lastEventId' => $lastEventId]); + } + + /** + * @return array + */ + public static function notStrings(): array + { + return [ + 'a repeated parameter' => [['1-0']], + 'an empty array' => [[]], + 'a nested map' => [['a' => '1-0']], + 'a boolean' => [true], + ]; + } + + /** + * `limit` and `timeout` stay forgiving where `lastEventId` does not: + * neither can cause a wrong answer. An array is garbage like any other. + */ + public function testServeFallsBackToTheDefaultsOnArrayLimitsAndTimeouts(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertCount(2, $this->server->serve(['limit' => ['5'], 'timeout' => ['0']])); + } + + public function testServeLetsTheTipSentinelThrough(): void + { + $this->producer->produce('a'); + + $this->assertCount(0, $this->server->serve(['lastEventId' => Readable::TIP])); + } + + /** + * The long-poll contract has to hold through the HTTP entry point too. A + * dropped timeout turns every long poll into a plain read, which no + * assertion on returned events can see β€” the batch is empty either way. + */ + public function testServeHoldsALongPollOnAnEmptyFeed(): void + { + $started = \microtime(true); + + $batch = $this->server->serve(['timeout' => '600']); + + $this->assertCount(0, $batch); + $this->assertGreaterThanOrEqual(0.4, \microtime(true) - $started, 'Must actually wait'); + } + + public function testServeFallsBackToTheDefaultOnAGarbageLimit(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $this->assertCount(2, $this->server->serve(['limit' => 'lots', 'timeout' => 'soon'])); + } + + /** + * The trap the old API set: a route that passed the raw request limit to + * the caching rule while the read was clamped to less would mark a full + * batch `no-store` β€” or worse. The batch carries the limit it was actually + * built with, so a full batch under an oversized request is still + * recognized as settled history. + */ + public function testAnOversizedLimitStillYieldsAnHonestCacheControl(): void + { + foreach (\range(1, Readable::MAX_BATCH) as $i) { + $this->producer->produce('event-' . $i); + } + + $batch = $this->server->serve(['limit' => '5000']); + + $this->assertCount(Readable::MAX_BATCH, $batch); + $this->assertSame('public, max-age=31536000', $batch->cacheControl(public: true)); + } + + public function testAShortBatchIsNotCacheable(): void + { + $this->producer->produce('a'); + + $this->assertSame('no-store', $this->server->serve([])->cacheControl()); + } + + public function testAnEmptyBatchIsNotCacheable(): void + { + $this->assertSame('no-store', $this->server->serve([])->cacheControl()); + } +} diff --git a/tests/Feed/Server/CacheTest.php b/tests/Feed/Server/CacheTest.php new file mode 100644 index 0000000..2c6e636 --- /dev/null +++ b/tests/Feed/Server/CacheTest.php @@ -0,0 +1,12 @@ +name)); + + $started = \microtime(true); + $events = $server->poll(null, 10, 5_000); + + $this->assertCount(1, $events); + $this->assertLessThan(3, \microtime(true) - $started, 'Must return on the event, not the timeout'); + } + + public function testAShorterPollIntervalDeliversAMidPollEventSooner(): void + { + $server = new Server(new MidPollStore($this->name, pollInterval: 20)); + + $started = \microtime(true); + $events = $server->poll(null, 10, 5_000); + $elapsed = \microtime(true) - $started; + + $this->assertCount(1, $events); + // Ignoring the configured interval falls back to the 500ms default. + $this->assertLessThan(0.45, $elapsed, 'A 20ms interval must beat the default 500ms floor'); + } +} diff --git a/tests/Feed/Server/PoolTest.php b/tests/Feed/Server/PoolTest.php new file mode 100644 index 0000000..91f031b --- /dev/null +++ b/tests/Feed/Server/PoolTest.php @@ -0,0 +1,47 @@ +name, pollInterval: 50); + + $events = (new Server($store))->poll(null, 10, 500); + + $this->assertCount(0, $events, 'The feed is empty, so the poll waits out its timeout'); + $this->assertGreaterThan( + 2, + $adapter->releases, + 'A ~500ms poll on a 50ms interval reads many times; borrowing once for the whole wait would release once', + ); + } + + /** Every borrow is given back β€” a leak fails nothing until the pool runs dry. */ + public function testEveryBorrowIsReturnedToThePool(): void + { + $this->producer->produce('a'); + + $this->server->read(); + $this->server->tip(); + $this->server->serve([]); + + $this->assertSame(self::POOL_SIZE, $this->pool()->count(), 'The pool is whole again'); + } +} diff --git a/tests/Feed/Server/RedisTest.php b/tests/Feed/Server/RedisTest.php new file mode 100644 index 0000000..a4930f9 --- /dev/null +++ b/tests/Feed/Server/RedisTest.php @@ -0,0 +1,12 @@ +fail(); + + return false; + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + $this->fail(); + + return false; + } + + public function touch(string $key, string $hash = ''): bool + { + return false; + } + + /** @return string[] */ + public function list(string $key): array + { + return []; + } + + public function purge(string $key, string $hash = ''): bool + { + $this->fail(); + + return false; + } + + public function flush(): bool + { + return false; + } + + public function ping(): bool + { + return false; + } + + public function getSize(): int + { + return 0; + } + + public function getName(?string $key = null): string + { + return 'broken'; + } + + private function fail(): void + { + if ($this->raises) { + throw new \RuntimeException('read error on connection to redis:6379'); + } + } +} diff --git a/tests/Feed/Support/CountingCache.php b/tests/Feed/Support/CountingCache.php new file mode 100644 index 0000000..ff97949 --- /dev/null +++ b/tests/Feed/Support/CountingCache.php @@ -0,0 +1,31 @@ + Reads per key. */ + public array $loads = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->loads[$key] = ($this->loads[$key] ?? 0) + 1; + + return parent::load($key, $ttl, $hash); + } + + public function forget(): void + { + $this->loads = []; + } + + public function reads(string $key): int + { + return $this->loads[$key] ?? 0; + } +} diff --git a/tests/Feed/Support/CountingStack.php b/tests/Feed/Support/CountingStack.php new file mode 100644 index 0000000..c58e94c --- /dev/null +++ b/tests/Feed/Support/CountingStack.php @@ -0,0 +1,23 @@ +releases++; + + return parent::push($connection); + } +} diff --git a/tests/Feed/Support/FailingCursor.php b/tests/Feed/Support/FailingCursor.php new file mode 100644 index 0000000..0879df4 --- /dev/null +++ b/tests/Feed/Support/FailingCursor.php @@ -0,0 +1,38 @@ +onLoad) { + throw new Transport('Cursor store is unavailable'); + } + + return parent::load($feed, $consumer); + } + + public function save(string $feed, string $consumer, string $eventId): void + { + if ($this->onSave) { + throw new Transport('Cursor store is unavailable'); + } + + parent::save($feed, $consumer, $eventId); + } +} diff --git a/tests/Feed/Support/FakeClient.php b/tests/Feed/Support/FakeClient.php new file mode 100644 index 0000000..7b6e643 --- /dev/null +++ b/tests/Feed/Support/FakeClient.php @@ -0,0 +1,104 @@ +respond($request); + + /** @var array $headers */ + $headers = \array_map( + static fn (array $values): string => \implode(', ', $values), + $request->getHeaders(), + ); + + $this->recorder->requests[] = [ + 'uri' => (string) $request->getUri(), + 'method' => $request->getMethod(), + 'headers' => $headers, + 'timeout' => $this->timeout, + 'status' => $response->getStatusCode(), + 'cacheControl' => $response->getHeaderLine(Header::CACHE_CONTROL), + 'contentType' => $response->getHeaderLine(Header::CONTENT_TYPE), + ]; + + return $response; + } + + /** + * @throws \Throwable To simulate a transport failure. + */ + abstract protected function respond(RequestInterface $request): ResponseInterface; + + public function withTimeout(float $seconds): static + { + $clone = clone $this; + $clone->timeout = $seconds; + + return $clone; + } + + public function withConnectTimeout(float $seconds): static + { + return clone $this; + } + + public function withSslVerification(bool $enabled = true): static + { + return clone $this; + } + + public function withCustomCA(string $path): static + { + return clone $this; + } + + public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static + { + return clone $this; + } + + public function withMinTlsVersion(Tls $version): static + { + return clone $this; + } + + public function withConnectionReuse(bool $enabled = true): static + { + return clone $this; + } + + /** + * Feeds are read buffered β€” a batch is bounded by `limit`, so there is + * nothing to stream. + */ + public function stream(RequestInterface $request, callable $sink): ResponseInterface + { + throw new \LogicException('A feed is never read as a stream'); + } +} diff --git a/tests/Feed/Support/FakeTransport.php b/tests/Feed/Support/FakeTransport.php new file mode 100644 index 0000000..e6ecbb5 --- /dev/null +++ b/tests/Feed/Support/FakeTransport.php @@ -0,0 +1,72 @@ + $responses Answered in order; + * the last one repeats once the script runs out. + */ + public static function of(array $responses): self + { + $transport = new self(); + $transport->recorder->responses = $responses; + + return $transport; + } + + /** + * @param array $body + */ + public static function json(array $body, int $statusCode = 200): ResponseInterface + { + return self::raw((string) \json_encode($body), $statusCode); + } + + public static function raw(string $body, int $statusCode = 200): ResponseInterface + { + return (new Response($statusCode, body: new Stream\Factory()->createStream($body))) + ->withHeader(Header::CONTENT_TYPE, ContentType::JSON); + } + + /** + * A transport failure, which PSR-18 requires be thrown rather than returned. + */ + public static function offline(string $message = 'Connection refused'): \Throwable + { + return new class ($message) extends \RuntimeException implements NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + throw new \LogicException('Not needed for this test'); + } + }; + } + + protected function respond(RequestInterface $request): ResponseInterface + { + $responses = &$this->recorder->responses; + + $response = \count($responses) > 1 ? \array_shift($responses) : ($responses[0] ?? null); + + if ($response instanceof \Throwable) { + throw $response; + } + + return $response ?? self::json([]); + } +} diff --git a/tests/Feed/Support/FeedServer.php b/tests/Feed/Support/FeedServer.php new file mode 100644 index 0000000..1c41624 --- /dev/null +++ b/tests/Feed/Support/FeedServer.php @@ -0,0 +1,66 @@ +server->getName()) { + return new Response(404, body: new Stream\Factory()->createStream('{"message":"No such feed"}')); + } + + $query = []; + \parse_str($request->getUri()->getQuery(), $query); + + $batch = $this->server->serve($query); + + $body = (string) \json_encode($batch->toArray()); + + return (new Response(200, body: new Stream\Factory()->createStream($body))) + ->withHeader(Header::CONTENT_TYPE, Batch::MEDIA_TYPE) + ->withHeader(Header::CACHE_CONTROL, $batch->cacheControl()); + } + + /** + * The feed name the request asks for: the last path segment, decoded. + * + * Split before decoding, never after β€” a feed called `a/b` arrives as + * `a%2Fb`. The path may also hold no slash at all, since a client with no + * base URI sends the bare name. + */ + private static function feed(RequestInterface $request): string + { + $path = $request->getUri()->getPath(); + $slash = \strrpos($path, '/'); + + return \rawurldecode($slash === false ? $path : \substr($path, $slash + 1)); + } +} diff --git a/tests/Feed/Support/MidPollStore.php b/tests/Feed/Support/MidPollStore.php new file mode 100644 index 0000000..f20012b --- /dev/null +++ b/tests/Feed/Support/MidPollStore.php @@ -0,0 +1,31 @@ +reads++ === 1) { + $this->append(new CloudEvent(id: '', type: $this->lands, source: 'urn:test')); + } + + return parent::read($lastEventId, $limit); + } +} diff --git a/tests/Feed/Support/Recorder.php b/tests/Feed/Support/Recorder.php new file mode 100644 index 0000000..4e19a11 --- /dev/null +++ b/tests/Feed/Support/Recorder.php @@ -0,0 +1,59 @@ +, timeout: float|null, status: int, cacheControl: string, contentType: string}> */ + public array $requests = []; + + /** + * Answers, in order. The last one repeats once the script runs out, so a + * test only scripts the responses it cares about. + * + * @var list + */ + public array $responses = []; + + /** + * @return array{uri: string, method: string, headers: array, timeout: float|null, status: int, cacheControl: string, contentType: string} + */ + public function last(): array + { + $request = \end($this->requests); + + if ($request === false) { + throw new \RuntimeException('No request was made'); + } + + return $request; + } + + /** + * @return list + */ + public function uris(): array + { + return \array_map(static fn (array $request): string => $request['uri'], $this->requests); + } + + /** + * @return list + */ + public function cacheControl(): array + { + return \array_map(static fn (array $request): string => $request['cacheControl'], $this->requests); + } +} diff --git a/tests/Feed/Support/RecordingStore.php b/tests/Feed/Support/RecordingStore.php new file mode 100644 index 0000000..519304b --- /dev/null +++ b/tests/Feed/Support/RecordingStore.php @@ -0,0 +1,30 @@ +lastEventId = $lastEventId; + $this->limit = $limit; + $this->timeout = $timeout; + + // Answered without waiting: the point is what was asked for. + return parent::poll($lastEventId, $limit, 0); + } +} diff --git a/tests/Feed/Support/UsesCache.php b/tests/Feed/Support/UsesCache.php new file mode 100644 index 0000000..ad65339 --- /dev/null +++ b/tests/Feed/Support/UsesCache.php @@ -0,0 +1,38 @@ +cache ??= new UtopiaCache(new CacheMemory()); + } + + protected function store(string $name, int $maxSize = 100_000, int $pollInterval = 500): Store&Appendable + { + return new CacheStore($this->cache(), $name, $maxSize, pollInterval: $pollInterval); + } + + protected function cursor(): Cursor + { + return new CacheCursor($this->cache()); + } +} diff --git a/tests/Feed/Support/UsesMemory.php b/tests/Feed/Support/UsesMemory.php new file mode 100644 index 0000000..3712ec2 --- /dev/null +++ b/tests/Feed/Support/UsesMemory.php @@ -0,0 +1,28 @@ +|null */ + private ?UtopiaPool $pool = null; + + /** + * A pool over the suite's Redis: the default {@see Stack}, or one that + * records borrows for a test asserting on how the pool is used. + * + * @return UtopiaPool<\Redis|\RedisCluster> + */ + protected static function poolOver(Stack $adapter): UtopiaPool + { + /** @var UtopiaPool<\Redis|\RedisCluster> $pool */ + $pool = new UtopiaPool($adapter, 'feed-tests', self::POOL_SIZE, static function (): \Redis { + $redis = new \Redis(); + $redis->connect((string) (\getenv('REDIS_HOST') ?: 'redis'), (int) (\getenv('REDIS_PORT') ?: 6379)); + + return $redis; + }); + + return $pool; + } + + /** @return UtopiaPool<\Redis|\RedisCluster> */ + protected function pool(): UtopiaPool + { + return $this->pool ??= self::poolOver(new Stack()); + } + + protected function store(string $name, int $maxSize = 100_000, int $pollInterval = 500): Store&Appendable + { + return new PoolStore($this->pool(), $name, $maxSize, $pollInterval); + } + + protected function cursor(): Cursor + { + return new PoolCursor($this->pool()); + } + + protected function tearDown(): void + { + $this->pool()->use(function (\Redis|\RedisCluster $redis): void { + foreach ((array) $redis->keys('feed:' . $this->name . '*') as $key) { + if (\is_string($key)) { + $redis->del($key); + } + } + }); + } +} diff --git a/tests/Feed/Support/UsesRedis.php b/tests/Feed/Support/UsesRedis.php new file mode 100644 index 0000000..6056545 --- /dev/null +++ b/tests/Feed/Support/UsesRedis.php @@ -0,0 +1,64 @@ +redis === null) { + $this->redis = new \Redis(); + $this->redis->connect((string) (\getenv('REDIS_HOST') ?: 'redis'), (int) (\getenv('REDIS_PORT') ?: 6379)); + } + + return $this->redis; + } + + /** + * A client that cannot reach a server: every command raises + * `\RedisException` from the extension itself. Closing a connected one + * would not do β€” phpredis reconnects on the next command. + */ + protected static function unreachableRedis(): \Redis + { + return new \Redis(); + } + + protected function store(string $name, int $maxSize = 100_000, int $pollInterval = 500): Store&Appendable + { + return new RedisStore($this->redis(), $name, $maxSize, $pollInterval); + } + + protected function cursor(): Cursor + { + return new RedisCursor($this->redis()); + } + + protected function tearDown(): void + { + foreach ((array) $this->redis()->keys('feed:' . $this->name . '*') as $key) { + if (\is_string($key)) { + $this->redis()->del($key); + } + } + + $this->redis()->close(); + $this->redis = null; + } +} diff --git a/tests/Feed/Unit/BatchTest.php b/tests/Feed/Unit/BatchTest.php new file mode 100644 index 0000000..c00564d --- /dev/null +++ b/tests/Feed/Unit/BatchTest.php @@ -0,0 +1,130 @@ + */ + private static function events(int $count): array + { + return \array_map( + static fn (int $i): CloudEvent => new CloudEvent(id: '1-' . $i, type: 'event-' . $i, source: 'urn:test'), + \range(0, $count - 1), + ); + } + + public function testCountsItsEvents(): void + { + $this->assertCount(3, new Batch(self::events(3), 100)); + $this->assertCount(0, new Batch([], 100)); + } + + public function testIteratesItsEventsInOrder(): void + { + $types = []; + + foreach (new Batch(self::events(2), 100) as $event) { + $types[] = $event->type; + } + + $this->assertSame(['event-0', 'event-1'], $types); + } + + public function testKnowsWhetherItIsEmpty(): void + { + $this->assertTrue((new Batch([], 100))->isEmpty()); + $this->assertFalse((new Batch(self::events(1), 100))->isEmpty()); + } + + public function testLastIdIsTheLastEventsPosition(): void + { + $this->assertSame('1-2', (new Batch(self::events(3), 100))->lastId()); + $this->assertNull((new Batch([], 100))->lastId()); + } + + /** + * The caching rule stays in one place β€” the batch answers with its own + * count and its own limit, so a mismatched pair cannot be expressed. + */ + public function testAFullBatchIsCacheable(): void + { + $this->assertSame('private, max-age=31536000', (new Batch(self::events(2), 2))->cacheControl()); + $this->assertSame('public, max-age=31536000', (new Batch(self::events(2), 2))->cacheControl(public: true)); + } + + public function testAShortOrEmptyBatchIsNot(): void + { + $this->assertSame('no-store', (new Batch(self::events(1), 2))->cacheControl()); + $this->assertSame('no-store', (new Batch([], 2))->cacheControl()); + } + + /** + * A batch of zero out of zero is not history β€” it is a caught-up consumer, + * and caching it would pin the consumer at that position forever. + */ + public function testAnEmptyBatchIsNeverCacheable(): void + { + $this->assertSame('no-store', (new Batch([], 0))->cacheControl()); + } + + public function testToArrayIsTheWireEncoding(): void + { + $payload = (new Batch(self::events(2), 100))->toArray(); + + $this->assertTrue(\array_is_list($payload), 'A batch is a plain array β€” the spec defines no envelope'); + $this->assertSame(['1-0', '1-1'], \array_column($payload, 'id')); + $this->assertSame('1.0', $payload[0]['specversion']); + $this->assertSame([], (new Batch([], 100))->toArray()); + } + + /** + * Asserted as a whole array: `id` and `specversion` alone would pass an + * encoder that dropped `subject` or an extension. + */ + public function testEveryAttributeReachesTheWire(): void + { + $batch = new Batch([new CloudEvent( + id: '1-0', + type: 'io.appwrite.edge.invalidate-rule', + source: 'urn:appwrite:cloud:fra', + subject: 'example.com', + time: '2026-07-31T09:15:02.123Z', + datacontenttype: 'application/json', + data: ['tags' => ['domain' => 'example.com']], + dataschema: 'https://example.com/schema.json', + extensions: ['traceparent' => '00-abc-def-01'], + )], 100); + + $this->assertSame([ + 'specversion' => '1.0', + 'type' => 'io.appwrite.edge.invalidate-rule', + 'source' => 'urn:appwrite:cloud:fra', + 'id' => '1-0', + 'subject' => 'example.com', + 'time' => '2026-07-31T09:15:02.123Z', + 'datacontenttype' => 'application/json', + 'dataschema' => 'https://example.com/schema.json', + 'data' => ['tags' => ['domain' => 'example.com']], + 'traceparent' => '00-abc-def-01', + ], $batch->toArray()[0]); + } + + /** The spec has no null attribute values, so an absent attribute is omitted. */ + public function testAnAbsentOptionalAttributeIsOmittedRatherThanNulled(): void + { + $encoded = (new Batch([new CloudEvent( + id: '1-0', + type: 'a', + source: 'urn:test', + datacontenttype: null, + )], 100))->toArray()[0]; + + $this->assertSame(['specversion', 'type', 'source', 'id'], \array_keys($encoded)); + } +} diff --git a/tests/Feed/Unit/ConsumerTest.php b/tests/Feed/Unit/ConsumerTest.php new file mode 100644 index 0000000..7ba4cb7 --- /dev/null +++ b/tests/Feed/Unit/ConsumerTest.php @@ -0,0 +1,316 @@ +store = new MemoryStore('edge'); + $this->producer = new Producer($this->store, 'urn:test'); + $this->cursor = new MemoryCursor(); + } + + private function consumer(?Cursor $cursor = null): Consumer + { + return new Consumer($this->store, $cursor ?? $this->cursor, 'invalidator'); + } + + public function testReadsTheStoreOnceAndThenTracksThePositionInMemory(): void + { + $this->producer->produce('a'); + + $cursor = new class () extends MemoryCursor { + public int $loads = 0; + + public function load(string $feed, string $consumer): ?string + { + $this->loads++; + + return parent::load($feed, $consumer); + } + }; + + $consumer = $this->consumer($cursor); + + // Two reads on the first pass β€” one restores the position, one is the + // conditional save's compare. The caught-up polls after it must not + // touch the store at all, which is what keeps an idle consumer on a + // timer free. + $consumer->consume(fn (CloudEvent $event) => null); + $this->assertSame(2, $cursor->loads); + + $consumer->consume(fn (CloudEvent $event) => null); + $consumer->consume(fn (CloudEvent $event) => null); + + $this->assertSame(2, $cursor->loads, 'An idle poll costs no cursor reads'); + } + + /** + * Two instances sharing a name share one position. A save is conditional + * on the position the run started from, so the instance that fell behind + * cannot undo the other's progress β€” it adopts the newer position and + * continues from there. + */ + public function testAStaleInstanceCannotUndoAnotherInstancesProgress(): void + { + $one = $this->consumer(); + $two = $this->consumer(); + + // An empty first run restores "no position yet" on both instances... + $this->assertSame(0, $one->consume(fn (CloudEvent $event) => null)); + $this->assertSame(0, $two->consume(fn (CloudEvent $event) => null)); + + foreach (['a', 'b', 'c', 'd'] as $type) { + $this->producer->produce($type); + } + + // ...then the first instance gets ahead. + $one->consume(fn (CloudEvent $event) => null); + $ahead = $one->position(); + $this->assertNotNull($ahead); + + // The stale instance re-handles from the start (at-least-once), but + // its save is refused rather than moving the shared position back. + $two->consume(fn (CloudEvent $event) => null); + $this->assertSame($ahead, $this->cursor->load('edge', 'invalidator')); + + // Having conceded, it reloads the shared position and stays there. + $this->assertSame($ahead, $two->position()); + } + + /** A seek made through one instance survives another instance's in-flight run. */ + public function testAStaleInstanceCannotUndoAnotherInstancesSeek(): void + { + $running = $this->consumer(); + $operator = $this->consumer(); + + // An empty first run restores "no position yet" before the seek lands. + $this->assertSame(0, $running->consume(fn (CloudEvent $event) => null)); + + foreach (['a', 'b'] as $type) { + $this->producer->produce($type); + } + + $operator->seek('9999999999999-0'); + + $running->consume(fn (CloudEvent $event) => null); + + $this->assertSame('9999999999999-0', $this->cursor->load('edge', 'invalidator')); + $this->assertSame('9999999999999-0', $running->position(), 'The refused instance adopts the seek'); + } + + /** + * The consumer clamps its own inputs: whatever the constructor was given, + * a store is never asked for more than the protocol allows. + */ + public function testClampsBatchAndTimeoutToTheProtocolLimits(): void + { + $store = new class ('edge') extends MemoryStore { + public ?int $limit = null; + + public ?int $timeout = null; + + public function poll(?string $lastEventId, int $limit, int $timeout): array + { + $this->limit = $limit; + $this->timeout = $timeout; + + return parent::poll($lastEventId, $limit, 0); + } + }; + + $consumer = new Consumer($store, $this->cursor, 'invalidator', batch: 5_000, timeout: 120_000); + $consumer->consume(fn (CloudEvent $event) => null); + + $this->assertSame(Readable::MAX_BATCH, $store->limit); + $this->assertSame(Readable::MAX_TIMEOUT, $store->timeout); + } + + /** + * A cursor store that is down surfaces rather than being swallowed: reading + * from an unknown position would replay the retained feed, so the run stops + * and the caller decides. + */ + public function testAPositionThatCannotBeLoadedStopsTheRun(): void + { + $this->producer->produce('a'); + + $consumer = $this->consumer(new FailingCursor(onLoad: true)); + $seen = []; + + try { + $consumer->consume(function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }); + $this->fail('The store failure should have been raised'); + } catch (Transport $error) { + $this->assertSame('Cursor store is unavailable', $error->getMessage()); + } + + $this->assertSame([], $seen, 'Nothing is handled from a position that could not be read'); + } + + /** + * The load is retried on the next run rather than being remembered as a + * failure, so a store that blips does not leave the consumer stuck. + */ + public function testAFailedLoadIsRetriedOnTheNextRun(): void + { + $this->producer->produce('a'); + + $cursor = new class () extends MemoryCursor { + public bool $fail = true; + + public function load(string $feed, string $consumer): ?string + { + if ($this->fail) { + $this->fail = false; + + throw new Transport('Cursor store is unavailable'); + } + + return parent::load($feed, $consumer); + } + }; + + $consumer = $this->consumer($cursor); + + try { + $consumer->consume(fn (CloudEvent $event) => null); + } catch (Transport) { + // Expected on the first run. + } + + $this->assertSame(1, $consumer->consume(fn (CloudEvent $event) => null), 'The second run reads the store again'); + } + + /** + * The events were handled, so the failure comes after them: this run keeps + * its progress in memory and only a restart replays. + */ + public function testAPositionThatCannotBeSavedIsRaisedAfterTheEventsAreHandled(): void + { + $this->producer->produce('a'); + $this->producer->produce('b'); + + $consumer = $this->consumer(new FailingCursor(onSave: true)); + $seen = []; + + try { + $consumer->consume(function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }); + $this->fail('The store failure should have been raised'); + } catch (Transport $error) { + $this->assertSame('Cursor store is unavailable', $error->getMessage()); + } + + $this->assertSame(['a', 'b'], $seen, 'The handler still saw the batch'); + $this->assertNotNull($consumer->position(), 'The in-memory position still moved'); + } + + /** + * A seek that did not persist must not look like one that did: the store + * failure surfaces, and the in-memory position stays where it was. + */ + public function testASeekThatCannotPersistFailsLoudlyAndMovesNothing(): void + { + $this->producer->produce('a'); + + $consumer = $this->consumer(new FailingCursor(onSave: true)); + + $this->assertNull($consumer->position()); + + try { + $consumer->seek('1-0'); + $this->fail('The store failure should have been raised'); + } catch (Transport) { + // Expected. + } + + $this->assertNull($consumer->position(), 'The in-memory position must not move on a failed seek'); + } + + /** + * The failure a consumer must not turn into a gap: if the read itself + * fails, nothing is handled and nothing is committed. + */ + public function testAFailedReadLeavesThePositionAlone(): void + { + $first = $this->producer->produce('a'); + $this->producer->produce('b'); + $this->cursor->save('edge', 'invalidator', $first); + + $consumer = new Consumer(new NoneStore('edge'), $this->cursor, 'invalidator'); + + $this->expectException(Unsupported::class); + + try { + $consumer->consume(fn (CloudEvent $event) => null); + } finally { + $this->assertSame($first, $this->cursor->load('edge', 'invalidator')); + } + } + + /** + * A client carries the endpoint but not the feed's name, so a consumer + * built over one has to be told which feed it is reading. + */ + public function testConsumingThroughAClientRequiresAFeedName(): void + { + $this->expectException(Invalid::class); + + new Consumer(FakeTransport::of([]), $this->cursor, 'invalidator'); + } + + /** + * A local store already names its feed. Repeating the name is harmless; + * contradicting it means the caller is confused about what they are + * reading, which must not resolve silently in either direction. + */ + public function testAFeedNameThatContradictsTheStoreIsRejected(): void + { + $this->expectException(Invalid::class); + + new Consumer($this->store, $this->cursor, 'invalidator', feed: 'other'); + } + + public function testAFeedNameThatMatchesTheStoreIsAccepted(): void + { + $this->producer->produce('a'); + + $consumer = new Consumer($this->store, $this->cursor, 'invalidator', feed: 'edge'); + + $this->assertSame(1, $consumer->consume(fn (CloudEvent $event) => null)); + } +} diff --git a/tests/Feed/Unit/CursorTest.php b/tests/Feed/Unit/CursorTest.php new file mode 100644 index 0000000..87fe119 --- /dev/null +++ b/tests/Feed/Unit/CursorTest.php @@ -0,0 +1,161 @@ +save('edge', 'invalidator', '1-0'); + + $this->assertNull($cursor->load('edge', 'invalidator'), 'Nothing is stored, so nothing comes back'); + } + + public function testResettingTheNoneStoreIsHarmless(): void + { + $cursor = new None(); + + $cursor->reset('edge', 'invalidator'); + + $this->assertNull($cursor->load('edge', 'invalidator')); + } + + /** + * A stand-in that accepted names a real store rejects would let a bug + * through in development and surface it in production instead. + * + * @return array + */ + public static function unusableNames(): array + { + return [ + 'no feed' => ['', 'invalidator'], + 'no consumer' => ['edge', ''], + ]; + } + + #[DataProvider('unusableNames')] + public function testTheNoneStoreStillRejectsEmptyNames(string $feed, string $consumer): void + { + $this->expectException(Invalid::class); + + (new None())->load($feed, $consumer); + } + + /** + * A cache answers a failed write with false rather than raising, so + * ignoring it reports a position as saved that was never stored. + */ + public function testACacheThatCannotBeWrittenRaisesTransport(): void + { + $cursor = new CacheCursor(new UtopiaCache(new BrokenCache())); + + $this->expectException(Transport::class); + + $cursor->save('edge', 'invalidator', '1-0'); + } + + /** + * The consequence through the API an operator reaches for: seek() persists + * immediately, so a failed one must not look like it worked. + */ + public function testASeekThroughAnUnwritableCacheFailsLoudly(): void + { + $store = new MemoryStore('edge'); + $poison = (new Producer($store, 'urn:test'))->produce('poison'); + + $consumer = new Consumer($store, new CacheCursor(new UtopiaCache(new BrokenCache())), 'invalidator'); + + try { + $consumer->seek($poison); + $this->fail('The store failure should have been raised'); + } catch (Transport) { + // Expected. + } + + $this->assertNull($consumer->position(), 'The in-memory position must not move on a failed seek'); + } + + /** + * Resetting is not held to the same rule: false also means "was never + * there", the ordinary case for a consumer with no position yet. + */ + public function testResettingAPositionThatWasNeverSavedIsHarmless(): void + { + $consumer = new Consumer(new MemoryStore('edge'), new CacheCursor(new UtopiaCache(new BrokenCache())), 'invalidator'); + + $consumer->reset(); + + $this->assertNull($consumer->position()); + $this->assertSame(0, $consumer->consume(fn (CloudEvent $event) => null)); + } + + /** + * The other way a cache fails: it lets the backend's own error out. + * Unwrapped, that escapes this library entirely. + * + * @param callable(Cursor): void $operation + */ + #[DataProvider('operations')] + public function testABackendThatIsDownRaisesTransport(callable $operation): void + { + $cursor = new CacheCursor(new UtopiaCache(new BrokenCache(raises: true))); + + $this->expectException(Transport::class); + + $operation($cursor); + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + 'load' => [static function (Cursor $cursor): void { + $cursor->load('edge', 'invalidator'); + }], + 'save' => [static function (Cursor $cursor): void { + $cursor->save('edge', 'invalidator', '1-0'); + }], + 'reset' => [static function (Cursor $cursor): void { + $cursor->reset('edge', 'invalidator'); + }], + ]; + } + + /** An unusable name stays Invalid even when the backend is also down. */ + #[DataProvider('unusableNames')] + public function testAnUnusableNameIsStillInvalidOnABackendThatIsDown(string $feed, string $consumer): void + { + $cursor = new CacheCursor(new UtopiaCache(new BrokenCache(raises: true))); + + $this->expectException(Invalid::class); + + $cursor->load($feed, $consumer); + } +} diff --git a/tests/Feed/Unit/IdTest.php b/tests/Feed/Unit/IdTest.php new file mode 100644 index 0000000..3b6262a --- /dev/null +++ b/tests/Feed/Unit/IdTest.php @@ -0,0 +1,81 @@ + + */ + public static function ids(): array + { + return [ + 'well formed' => ['1690000000000-0', true], + 'high sequence' => ['1690000000000-42', true], + 'zero' => ['0-0', true], + 'empty' => ['', false], + 'no sequence' => ['1690000000000', false], + 'not numeric' => ['abc-0', false], + 'negative' => ['-1-0', false], + 'trailing dash' => ['1690000000000-', false], + 'exclusive syntax' => ['(1690000000000-0', false], + 'range token' => ['-', false], + 'tip sentinel' => ['$', false], + ]; + } + + #[DataProvider('ids')] + public function testValidatesIds(string $id, bool $valid): void + { + $this->assertSame($valid, Id::isValid($id)); + } + + public function testAfterIsTheNextSequenceInTheSameMillisecond(): void + { + $this->assertSame('1690000000000-1', Id::after('1690000000000-0')); + $this->assertSame('1690000000000-43', Id::after('1690000000000-42')); + } + + public function testAfterRejectsAnIdThatIsNotAPosition(): void + { + $this->expectException(Invalid::class); + + Id::after('not-an-id'); + } + + public function testDecodeSplitsAnIdIntoItsParts(): void + { + $this->assertSame([1690000000000, 7], Id::decode('1690000000000-7')); + } + + public function testEncodeAndDecodeAreInverses(): void + { + $this->assertSame([12, 34], Id::decode(Id::encode(12, 34))); + } + + /** + * The reason positions are compared as decoded parts rather than as + * strings: `10-0` sorts before `9-0` lexically, which would make a consumer + * treat a newer event as one it had already passed. + */ + public function testDecodedIdsCompareInFeedOrderNotLexically(): void + { + $this->assertGreaterThan(Id::decode('9-0'), Id::decode('10-0')); + $this->assertGreaterThan(Id::decode('10-1'), Id::decode('10-2')); + $this->assertSame(Id::decode('10-0'), Id::decode('10-0')); + } + + public function testDecodeRejectsAnIdThatIsNotAPosition(): void + { + $this->expectException(Invalid::class); + + Id::decode('nope'); + } +} diff --git a/tests/Feed/Unit/KeyTest.php b/tests/Feed/Unit/KeyTest.php new file mode 100644 index 0000000..95e6ae2 --- /dev/null +++ b/tests/Feed/Unit/KeyTest.php @@ -0,0 +1,74 @@ +assertSame('feed:edge', Key::feed('edge')); + $this->assertSame('feed:edge:cursor:invalidator', Key::cursor('edge', 'invalidator')); + } + + /** A feed named `edge:cursor:x` used to take consumer `x`'s cursor key on `edge`. */ + public function testAFeedNameCannotCollideWithACursorKey(): void + { + $this->assertNotSame(Key::feed('edge:cursor:x'), Key::cursor('edge', 'x')); + } + + /** And the one that silently shares a position between two unrelated consumers. */ + public function testTwoDistinctPairsCannotShareACursorKey(): void + { + $this->assertNotSame(Key::cursor('a:cursor:b', 'c'), Key::cursor('a', 'b:cursor:c')); + } + + /** Without escaping the escape character, `a:b` and `a%3Ab` would still collide. */ + public function testTheEscapeCharacterIsItselfEscaped(): void + { + $this->assertNotSame(Key::feed('a:b'), Key::feed('a%3Ab')); + $this->assertNotSame(Key::cursor('a:b', 'c'), Key::cursor('a%3Ab', 'c')); + } + + /** + * @return array + */ + public static function names(): array + { + return [ + 'separator' => ['a:b'], + 'escape character' => ['a%b'], + 'both' => ['a%3A:b'], + 'the cursor infix' => ['edge:cursor:x'], + 'unicode' => ['ΓΌnΓ―cΓΈde'], + 'slashes' => ['a/b'], + ]; + } + + #[DataProvider('names')] + public function testEveryNameKeepsItsOwnKeys(string $name): void + { + $others = \array_column(self::names(), 0); + + foreach ($others as $other) { + if ($other === $name) { + continue; + } + + $this->assertNotSame(Key::feed($other), Key::feed($name)); + $this->assertNotSame(Key::cursor($other, 'c'), Key::cursor($name, 'c')); + $this->assertNotSame(Key::cursor('f', $other), Key::cursor('f', $name)); + } + } +} diff --git a/tests/Feed/Unit/ProducerTest.php b/tests/Feed/Unit/ProducerTest.php new file mode 100644 index 0000000..aab57f3 --- /dev/null +++ b/tests/Feed/Unit/ProducerTest.php @@ -0,0 +1,45 @@ +expectException(\TypeError::class); + + // @phpstan-ignore argument.type + new Producer($remote, 'urn:appwrite:edge:fra'); + } + + public function testAFeedWithNoBackendFailsLoudlyRatherThanDroppingEvents(): void + { + $producer = new Producer(new None('edge'), 'urn:appwrite:cloud:fra'); + + $this->expectException(Unsupported::class); + + $producer->produce('test'); + } +} diff --git a/tests/Feed/Unit/RemoteTest.php b/tests/Feed/Unit/RemoteTest.php new file mode 100644 index 0000000..d4c8673 --- /dev/null +++ b/tests/Feed/Unit/RemoteTest.php @@ -0,0 +1,609 @@ + $responses + * @return array{Remote, FakeTransport} + */ + private function remote(array $responses = []): array + { + $transport = FakeTransport::of($responses); + + return [new Remote($transport, 'edge'), $transport]; + } + + /** + * A response body as a producer would put it on the wire. + * + * @param list $events + * @return list> + */ + private static function batch(array $events): array + { + return (new Batch($events, \count($events)))->toArray(); + } + + public function testReadsAFeedOverHttp(): void + { + [$remote] = $this->remote([FakeTransport::json(self::batch([ + new CloudEvent(id: '1-0', type: 'io.appwrite.edge.invalidate-rule', source: 'urn:test', data: ['tags' => ['domain' => 'example.com']]), + new CloudEvent(id: '1-1', type: 'io.appwrite.edge.invalidate', source: 'urn:test'), + ]))]); + + $events = $remote->read(); + + $this->assertCount(2, $events); + $this->assertSame('1-0', $events[0]->id); + $this->assertSame(['tags' => ['domain' => 'example.com']], $events[0]->data); + } + + /** + * The endpoint lives on the client β€” the feed asks for its name as a + * relative path and the client resolves it against its base URI. + */ + public function testResolvesTheFeedNameAgainstTheClientsBaseUri(): void + { + $transport = FakeTransport::of([]); + $client = (new Client($transport))->withBaseUri('https://cloud.example.com/v1/feeds'); + + (new Remote($client, 'edge'))->read(); + + $this->assertStringStartsWith('https://cloud.example.com/v1/feeds/edge', $transport->recorder->last()['uri']); + } + + public function testEncodesAFeedNameThatNeedsIt(): void + { + $transport = FakeTransport::of([]); + $client = (new Client($transport))->withBaseUri('https://cloud.example.com/v1/feeds/'); + + (new Remote($client, 'a b/c'))->read(); + + $this->assertStringStartsWith( + 'https://cloud.example.com/v1/feeds/a%20b%2Fc', + $transport->recorder->last()['uri'], + ); + } + + public function testRejectsAnEmptyFeedName(): void + { + $this->expectException(Invalid::class); + + new Remote(FakeTransport::of([]), ''); + } + + public function testExposesTheFeedItReads(): void + { + [$remote] = $this->remote(); + + $this->assertSame('edge', $remote->getName()); + } + + /** + * The tip is the producer's to resolve β€” a consumer starting at the tip + * sends the sentinel instead of asking for the newest id first. + */ + public function testARemoteFeedHasNoLocalTip(): void + { + [$remote] = $this->remote(); + + $this->expectException(Unsupported::class); + + $remote->tip(); + } + + public function testReadsWithGet(): void + { + [$remote, $transport] = $this->remote(); + + $remote->read(); + + $this->assertSame('GET', $transport->recorder->last()['method']); + } + + public function testAsksForTheFeedMediaType(): void + { + [$remote, $transport] = $this->remote(); + + $remote->read(); + + $this->assertSame(Remote::MEDIA_TYPE, $transport->recorder->last()['headers']['Accept'] ?? null); + $this->assertSame('application/cloudevents-batch+json', Remote::MEDIA_TYPE); + } + + public function testSendsThePositionAndLimit(): void + { + [$remote, $transport] = $this->remote(); + + $remote->read('1-0', 250); + + $uri = $transport->recorder->last()['uri']; + + $this->assertStringContainsString('lastEventId=1-0', $uri); + $this->assertStringContainsString('limit=250', $uri); + } + + public function testSendsNoPositionOnAFirstFullRead(): void + { + [$remote, $transport] = $this->remote(); + + $remote->read(null, Readable::MAX_BATCH); + + $this->assertStringNotContainsString('lastEventId', $transport->recorder->last()['uri']); + } + + /** + * The producer does the waiting, so a poll is one request rather than a + * client-side loop. + */ + public function testDelegatesLongPollingToTheProducer(): void + { + [$remote, $transport] = $this->remote(); + + $started = \microtime(true); + $remote->poll(null, 100, 5000); + + // The request count is the real check; the clock is bounded by the 5s + // a client-side loop would take, not by the ~0s this takes. + $this->assertLessThan(1, \microtime(true) - $started, 'Must not wait client-side'); + $this->assertCount(1, $transport->recorder->requests, 'Must not poll in a loop'); + $this->assertStringContainsString('timeout=5000', $transport->recorder->last()['uri']); + } + + /** + * Without the margin the client's deadline races the producer's, and a + * poll that correctly waits out its timeout surfaces as a failure on every + * quiet tick. + */ + public function testAllowsTheClientLongerThanTheLongPollTimeout(): void + { + [$remote, $transport] = $this->remote(); + + $remote->poll(null, 100, 5000); + + // Seconds, which is what the client takes; the margin is in + // milliseconds, like the timeout the producer is given. + $this->assertSame(15.0, $transport->recorder->last()['timeout']); + } + + public function testLeavesTheConfiguredTimeoutAloneWhenNotLongPolling(): void + { + [$remote, $transport] = $this->remote(); + + $remote->read(); + + $this->assertNull($transport->recorder->last()['timeout'], 'A plain read must not override the client'); + } + + /** + * The status is carried on the exception so a consumer can tell a producer + * that does not serve the feed yet β€” normal during a staged rollout β€” from + * one that is broken. + */ + public function testCarriesTheStatusOfARejectedRead(): void + { + [$remote] = $this->remote([FakeTransport::json([], 404)]); + + try { + $remote->read(); + $this->fail('A 404 should have been raised'); + } catch (Transport $error) { + $this->assertSame(404, $error->getCode()); + } + } + + public function testRaisesServerErrors(): void + { + [$remote] = $this->remote([FakeTransport::json([], 503)]); + + try { + $remote->read(); + $this->fail('A 503 should have been raised'); + } catch (Transport $error) { + $this->assertSame(503, $error->getCode()); + } + } + + /** + * PSR-18 returns 4xx and 5xx rather than throwing, so the transport has to + * check the status itself β€” a producer error must not read as an empty + * batch, which the consumer would take for "caught up". + */ + public function testAnErrorStatusIsNotMistakenForAnEmptyBatch(): void + { + [$remote] = $this->remote([FakeTransport::json([], 500)]); + + $this->expectException(Transport::class); + + $remote->read(); + } + + public function testWrapsATransportFailure(): void + { + [$remote] = $this->remote([FakeTransport::offline()]); + + $this->expectException(Transport::class); + $this->expectExceptionMessageMatches('/Connection refused/'); + + $remote->read(); + } + + public function testWrapsABodyThatIsNotJson(): void + { + [$remote] = $this->remote([FakeTransport::raw('502 Bad Gateway')]); + + $this->expectException(Transport::class); + + $remote->read(); + } + + public function testRejectsABodyThatIsNotABatch(): void + { + [$remote] = $this->remote([FakeTransport::raw('"a string"')]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + /** + * An empty batch means "you are caught up". A JSON object means "you did + * not reach the feed" β€” a misrouted request, a proxy's JSON error page, an + * endpoint that moved. Reading one as an empty batch would leave a + * consumer sitting quietly at a position that never advances again. + * + * @param array $payload + */ + #[DataProvider('notBatches')] + public function testARespondingEndpointThatIsNotAFeedIsNotMistakenForBeingCaughtUp(array $payload): void + { + [$remote] = $this->remote([FakeTransport::json($payload)]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + /** + * The empty object is the one non-batch json_decode() cannot expose: `{}` + * and `[]` both decode to the same empty array, and only one of them + * means "caught up". + */ + public function testAnEmptyObjectIsNotMistakenForBeingCaughtUp(): void + { + [$remote] = $this->remote([FakeTransport::raw('{}')]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + /** + * @return array}> + */ + public static function notBatches(): array + { + return [ + 'the old envelope' => [['total' => 0, 'events' => []]], + 'some other API' => [['data' => [], 'status' => 'ok']], + 'an error body' => [['message' => 'Not found', 'code' => 404]], + ]; + } + + /** + * One event as a producer would put it on the wire. + * + * Overrides win, and are unioned rather than merged: `array_merge()` + * renumbers a digits-only name, losing it inside the fixture. + * + * @param array $overrides + * @return array + */ + private static function raw(string $id, string $type, array $overrides = []): array + { + return $overrides + [ + 'specversion' => '1.0', + 'id' => $id, + 'type' => $type, + 'source' => 'urn:test', + ]; + } + + /** + * An event with no id has no position, so a consumer cannot record having + * passed it. Returning the usable prefix lets those events be handled and + * the position advance to the last of them; the broken event is then at + * the head of the next batch, where it stops the feed loudly. + */ + public function testKeepsTheEventsBeforeAnUndecodableOne(): void + { + [$remote] = $this->remote([FakeTransport::json([ + self::raw('1-0', 'a'), + self::raw('1-1', 'b'), + self::raw('', 'no id'), + self::raw('1-3', 'd'), + ])]); + + $events = $remote->read(); + + $this->assertCount(2, $events); + $this->assertSame(['a', 'b'], \array_map(fn (CloudEvent $e): string => $e->type, $events)); + } + + public function testFailsWhenTheFirstEventIsUndecodable(): void + { + [$remote] = $this->remote([FakeTransport::json([self::raw('', 'no id'), self::raw('1-1', 'b')])]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + public function testFailsWhenTheFirstEntryIsNotAnEvent(): void + { + [$remote] = $this->remote([FakeTransport::json(['a string'])]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + public function testKeepsTheEventsBeforeAnEntryThatIsNotAnEvent(): void + { + [$remote] = $this->remote([FakeTransport::json([self::raw('1-0', 'a'), 'a string'])]); + + $this->assertCount(1, $remote->read()); + } + + /** + * `specversion` is REQUIRED by the spec and a feed's own producer always + * sends it, so an entry without one is not a CloudEvent at all β€” the batch + * stops there rather than the attribute being invented. + */ + public function testFailsWhenAnEventIsNotACloudEventAtAll(): void + { + [$remote] = $this->remote([FakeTransport::json([['id' => '1-0', 'type' => 'a']])]); + + $this->expectException(Invalid::class); + + $remote->read(); + } + + /** + * Every attribute this library models has to come off the wire, not only + * the ones a test asserting on `id` and `data` happens to look at. + */ + public function testEveryModelledAttributeComesOffTheWire(): void + { + [$remote] = $this->remote([FakeTransport::json([self::raw('1-0', 'io.appwrite.edge.invalidate-rule', [ + 'subject' => 'example.com', + 'time' => '2026-07-31T09:15:02.123Z', + 'datacontenttype' => 'application/xml', + 'dataschema' => 'https://example.com/schema.json', + 'data' => '', + 'traceparent' => '00-abc-def-01', + ])])]); + + $event = $remote->read()[0]; + + $this->assertSame('1-0', $event->id); + $this->assertSame('io.appwrite.edge.invalidate-rule', $event->type); + $this->assertSame('urn:test', $event->source); + $this->assertSame('1.0', $event->specversion); + $this->assertSame('example.com', $event->subject); + $this->assertSame('2026-07-31T09:15:02.123Z', $event->time); + $this->assertSame('application/xml', $event->datacontenttype); + $this->assertSame('https://example.com/schema.json', $event->dataschema); + $this->assertSame('', $event->data); + $this->assertSame('00-abc-def-01', $event->extensions['traceparent']); + } + + /** An absent `datacontenttype` means JSON, so it must not be invented on decode. */ + public function testAnAbsentDatacontenttypeIsNotInvented(): void + { + [$remote] = $this->remote([FakeTransport::json([self::raw('1-0', 'a')])]); + + $this->assertNull($remote->read()[0]->datacontenttype); + } + + /** + * The forward-compatibility property a feed depends on: it is read by + * consumers older than the producer by design, so a producer that adds an + * attribute or moves the spec version forward must not stop one that + * predates it. + */ + public function testSurvivesAProducerThatMovedAhead(): void + { + [$remote] = $this->remote([FakeTransport::json([ + self::raw('1-0', 'a', [ + 'specversion' => '1.1', + 'somethingnew' => 'ignored', + 'traceparent' => '00-abc-def-01', + ]), + ])]); + + $events = $remote->read(); + + $this->assertCount(1, $events); + $this->assertSame('1.1', $events[0]->specversion); + $this->assertSame('00-abc-def-01', $events[0]->extensions['traceparent']); + } + + /** + * An attribute the spec cannot carry is dropped and the event still + * delivered, rather than one odd attribute costing the whole event. + * Dropping is a choice, so each shape it takes is named here. + * + * @param array $extension + */ + #[DataProvider('unusableExtensions')] + public function testAnExtensionTheSpecCannotCarryIsDroppedAndTheEventKept(array $extension): void + { + [$remote] = $this->remote([FakeTransport::json([ + self::raw('1-0', 'a', $extension + ['keeps' => 'this one']), + ])]); + + $events = $remote->read(); + + $this->assertCount(1, $events, 'The event is still delivered'); + $this->assertSame(['keeps' => 'this one'], $events[0]->extensions); + } + + /** + * @return array}> + */ + public static function unusableExtensions(): array + { + return [ + // A JSON number with a decimal point decodes as a float. + 'a float value' => [['ratio' => 1.5]], + 'an array value' => [['tags' => ['a', 'b']]], + 'an object value' => [['nested' => ['a' => 'b']]], + 'a null value' => [['missing' => null]], + 'an uppercase name' => [['traceParent' => '00-abc-def-01']], + 'a name with a dash' => [['trace-parent' => '00-abc-def-01']], + 'a name with an underscore' => [['trace_parent' => '00-abc-def-01']], + ]; + } + + /** The types the spec allows, including a digits-only name β€” an integer key in PHP. */ + public function testEveryExtensionTheSpecAllowsIsKept(): void + { + [$remote] = $this->remote([FakeTransport::json([ + self::raw('1-0', 'a', ['trace' => 'abc', 'retrycount' => 2, 'replayed' => true, '123' => 'digits']), + ])]); + + $extensions = $remote->read()[0]->extensions; + + $this->assertSame('abc', $extensions['trace']); + $this->assertSame(2, $extensions['retrycount']); + $this->assertTrue($extensions['replayed']); + // @phpstan-ignore offsetAccess.notFound ('123' is an integer key in PHP) + $this->assertSame('digits', $extensions['123']); + } + + /** + * The spec's optional compaction/deletion feature marks an event with a + * `method` attribute. This library does not implement the feature, but a + * feed that uses it must still be readable β€” the attribute rides along as + * an extension rather than breaking the batch. + */ + public function testAnEventCarryingTheSpecsMethodAttributeDecodes(): void + { + [$remote] = $this->remote([FakeTransport::json([self::raw('1-0', 'a', ['method' => 'DELETE'])])]); + + $events = $remote->read(); + + $this->assertCount(1, $events); + $this->assertSame('DELETE', $events[0]->extensions['method']); + } + + /** + * Anything implementing the client's adapter interface works, including + * the client itself wrapping a transport β€” which is how this is actually + * built in a service. + */ + public function testWorksThroughTheClientItself(): void + { + $transport = FakeTransport::of([FakeTransport::json(self::batch([new CloudEvent(id: '1-0', type: 'a', source: 'urn:test')]))]); + + $client = (new Client($transport)) + ->withBaseUri('https://cloud.example.com/v1/feeds') + ->withHeaders(['x-appwrite-jwt' => 'token']); + $remote = new Remote($client, 'edge'); + + $events = $remote->read(); + + $this->assertCount(1, $events); + $this->assertSame('token', $transport->recorder->last()['headers']['x-appwrite-jwt'] ?? null); + } + + /** + * The point of this class: a remote feed is consumed with exactly the + * code a local one is. The consumer is built straight over the client β€” + * it wraps the feed name and the client into a Remote itself. + */ + public function testConsumesARemoteFeedThroughTheSameConsumer(): void + { + $transport = FakeTransport::of([ + FakeTransport::json(self::batch([ + new CloudEvent(id: '1-0', type: 'a', source: 'urn:test'), + new CloudEvent(id: '1-1', type: 'b', source: 'urn:test'), + ])), + FakeTransport::json(self::batch([new CloudEvent(id: '1-2', type: 'c', source: 'urn:test')])), + FakeTransport::json(self::batch([])), + ]); + + $cursor = new MemoryCursor(); + $consumer = new Consumer($transport, $cursor, 'invalidator', feed: 'edge'); + + $seen = []; + $handler = function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }; + + $this->assertSame(2, $consumer->consume($handler)); + $this->assertSame('1-1', $cursor->load('edge', 'invalidator')); + + $this->assertSame(1, $consumer->consume($handler)); + $this->assertSame(0, $consumer->consume($handler)); + + $this->assertSame(['a', 'b', 'c'], $seen); + $this->assertStringContainsString('lastEventId=1-1', $transport->recorder->uris()[1]); + } + + /** + * The tip sentinel crosses the wire as `lastEventId=$` and the producer + * resolves it inside the held request β€” end to end, a tip consumer skips + * the backlog and still gets what lands mid-poll. + */ + public function testTipStartWorksOverHttp(): void + { + $store = new MidPollStore('edge'); + $producer = new Producer($store, 'urn:test'); + $producer->produce('old'); + + $endpoint = new FeedServer(new Server($store)); + $consumer = new Consumer($endpoint, new MemoryCursor(), 'notifier', feed: 'edge', timeout: 5_000, start: Consumer::START_TIP); + + $seen = []; + $handler = function (CloudEvent $event) use (&$seen): void { + $seen[] = $event->type; + }; + + $this->assertSame(1, $consumer->consume($handler)); + $this->assertSame(['landed'], $seen, 'The backlog is skipped; the mid-wait event is not'); + $this->assertMatchesRegularExpression('/lastEventId=(%24|\$)/', $endpoint->recorder->last()['uri']); + + // The position now saves as a real id, so the sentinel never + // appears on the wire again. + $producer->produce('after'); + + $this->assertSame(1, $consumer->consume($handler)); + $this->assertSame(['landed', 'after'], $seen); + $this->assertDoesNotMatchRegularExpression('/lastEventId=(%24|\$)/', $endpoint->recorder->last()['uri']); + } +} diff --git a/tests/Feed/Unit/ServerTest.php b/tests/Feed/Unit/ServerTest.php new file mode 100644 index 0000000..4a7d4a2 --- /dev/null +++ b/tests/Feed/Unit/ServerTest.php @@ -0,0 +1,111 @@ +expectException(Unsupported::class); + + $server->read(); + } + + /** + * `timeout` arrives from an untrusted client, so this clamp keeps + * `?timeout=86400000` from holding a worker for as long as it asks. + * + * @param array $query + */ + #[DataProvider('timeouts')] + public function testServeClampsTheTimeoutItForwards(array $query, int $expected): void + { + $store = new RecordingStore('edge'); + + (new Server($store))->serve($query); + + $this->assertSame($expected, $store->timeout); + } + + /** + * @return array, int}> + */ + public static function timeouts(): array + { + return [ + 'forwarded as given' => [['timeout' => '2500'], 2_500], + 'capped at the protocol maximum' => [['timeout' => '120000'], Readable::MAX_TIMEOUT], + 'a negative timeout floors at zero' => [['timeout' => '-5000'], 0], + 'garbage falls back to no wait' => [['timeout' => 'soon'], 0], + 'absent means no wait' => [[], 0], + ]; + } + + /** + * `limit` is clamped for the same reason and against the same caller. + * + * @param array $query + */ + #[DataProvider('limits')] + public function testServeClampsTheLimitItForwards(array $query, int $expected): void + { + $store = new RecordingStore('edge'); + + (new Server($store))->serve($query); + + $this->assertSame($expected, $store->limit); + } + + /** + * @return array, int}> + */ + public static function limits(): array + { + return [ + 'forwarded as given' => [['limit' => '25'], 25], + 'capped at the protocol maximum' => [['limit' => '5000'], Readable::MAX_BATCH], + 'zero floors at one' => [['limit' => '0'], 1], + 'a negative limit floors at one' => [['limit' => '-5'], 1], + 'garbage falls back to the maximum' => [['limit' => 'lots'], Readable::MAX_BATCH], + ]; + } + + /** The same clamps apply when a route reaches poll() rather than serve(). */ + public function testPollClampsTheTimeoutAndLimitToo(): void + { + $store = new RecordingStore('edge'); + + (new Server($store))->poll(null, 5_000, 120_000); + + $this->assertSame(Readable::MAX_TIMEOUT, $store->timeout); + $this->assertSame(Readable::MAX_BATCH, $store->limit); + } + + /** A position reaches the store as given, so clamping is all serve() does. */ + public function testServeForwardsThePositionUntouched(): void + { + $store = new RecordingStore('edge'); + + (new Server($store))->serve(['lastEventId' => '1-7']); + + $this->assertSame('1-7', $store->lastEventId); + } +}