Redis stringifies scalars on save, so on a cache hit the `total` field
was served as a string. Flutter SDK (and any strictly-typed client) then
failed with `TypeError: "37": type 'String' is not a subtype of type 'int'`.
The cache-miss path returned an int from `count()`, which is why only
repeat requests with `ttl > 0` tripped the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-call $measure closure with a single $dbStart
timestamp taken right before the fetch block and a single subtraction
right after it. Drops 6 lines of HOF indirection plus the $measure
variable, at the cost of including cache GET/SET time (~0.5–5ms) in
measurements when ttl > 0. For slow-query logging at a 100ms+
threshold that noise is negligible, and the default ttl=0 path has
no cache ops at all so the measurement is pure DB engine time.
The bracket captures the cursor lookup, find/count, and transaction
state calls — everything between "query parsed" and "fetch done",
as intended. processDocument's post-fetch relationship work is still
outside the bracket, matching the original design.
Moves the DB-duration measurement and afterQuery() hook from the
tablesDB-specific Rows/XList into the shared
Databases/Collections/Documents/XList base. Because TablesDB Rows and
DocumentsDB Documents both extend the legacy listDocuments base, a
single override now covers all three endpoints: legacy
listDocuments, listDocumentsDBDocuments, and tablesDB listRows.
TablesDB Rows drops the ~200-line action() duplicate and keeps only
the path/params/SDK overrides it needs, plus the extra
->inject('utopia') so its injection chain matches the new base action
signature. DocumentsDB Documents gets the same one-line inject
addition. Net -165 lines of duplication removed.
Behaviour is unchanged for CE (afterQuery() is a no-op); downstream
distributions overriding afterQuery() now observe every list-documents
/ list-rows call site for free.
Wrap each database call (find, count, transaction list/count) with a
measuring closure so the actual DB duration is known — cache hits
report near-zero, cache misses report only the DB time, not cache
save / response serialization.
After the response is sent, invoke a protected afterQuery() hook with
the measured duration, the database/collection documents, and both
parsed + raw query arrays. CE impl is a no-op; downstreams (e.g.,
cloud) can override it to log slow queries without relying on HTTP
shutdown hooks or route-path matching.
Exceptions from afterQuery are swallowed so observability never
breaks the response.
Per review feedback on the PHPStan cleanup, the two `if
($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE)`
blocks in `app/controllers/general.php` and
`src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php`
were load-bearing feature flags, not dead code. Removing them silently
dropped the ability to turn the cleanup on later.
Changes:
- Convert `ENABLE_EXECUTIONS_LIMIT_ON_ROUTE` from
`const ... = false;` to a `define()` backed by the new
`_APP_EXECUTIONS_LIMIT_ON_ROUTE` env var (defaults to `disabled`).
PHPStan can no longer fold the `&&` away since the value is now
runtime-resolved, so the guarded blocks are live again.
- Restore the `/* cleanup */` block in the `router()` helper in
`app/controllers/general.php`.
- Restore the two cleanup blocks in `Functions/Http/Executions/Create.php`
(one on the async-scheduled return path, one on the sync-response
path), and re-add the `DeleteEvent $queueForDeletes` /
`int $executionsRetentionCount` injections plus the
`Appwrite\Event\Delete` import.
Runtime behavior is identical to main (flag off by default); operators
can now flip it via env without a code change.
Three follow-ups from CI that the level-4 pass got wrong:
1. `account.php` / `users.php`: `Document::find()` returns `mixed`
(specifically `Document|false` in practice), not `Document`. The
earlier `@var Document $oldTarget` docblocks were lies, and the
runtime `instanceof Document` guards were load-bearing — removing
them caused `Call to a member function isEmpty() on false` 500s
on the `PATCH /v1/users/:id/email` and `/phone` endpoints (and the
analogous `/v1/account/email`, `/v1/account/phone` flows). Dropped
the misleading `@var` docblocks and restored
`$oldTarget instanceof Document && !$oldTarget->isEmpty()`.
2. `Installer/Runtime/Config::setEnabledDatabases()` is a boundary
that actually takes arbitrary user/compose input — not a trusted
`string[]`. The `is_string($v)` filter was covering for that, and
`ConfigTest::testSetEnabledDatabasesFiltersInvalid` explicitly
asserts it. Widened the PHPDoc to `array<mixed>` and restored
`is_string($v) && $v !== ''` in the filter.
3. `OAuth2/Apple::getAppSecret()` wrapped `json_decode` in a
`try/catch (\Throwable)` — but `json_decode` without
`JSON_THROW_ON_ERROR` returns `null` on failure, it doesn't throw.
PHP 8.3's PHPStan flagged the catch as dead (PHP 8.5 didn't, which
is why it slipped through locally). Replaced with
`if (!\is_array($secret)) throw`, which preserves the original
"invalid secret" guard.
Raises `phpstan.neon` level from 3 to 4 and fixes the 549 new errors
that level 4 surfaces across 157 files. Fixes are root-cause — no
`@phpstan-ignore`, no `@var` casts, no baseline entries, no widened
types. A handful of latent bugs were fixed along the way:
- `app/controllers/general.php`: path-traversal guard was negating
`\substr(...)` before the strict comparison (`!\substr(...) === $base`
was always `false === $base`). Rewritten as `\substr(...) !== $base`.
- `src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php`
and `.../TablesDB/Logs/XList.php`: were importing the raw Matomo
`DeviceDetector` (whose `getDevice()` returns `?int`) but treating the
result as an array with `deviceName/deviceBrand/deviceModel` keys.
Swapped to `Appwrite\Detector\Detector`, matching the wrapper already
used a few lines below for `$os`/`$client`.
- `src/Appwrite/Platform/Modules/Functions/Workers/Builds.php`: a match
key was checking `$resourceKey === 'functions'` when `$resourceKey`
is `'functionId'|'siteId'` — always false. Switched to the intended
`$resource->getCollection() === 'functions'` check.
- `src/Appwrite/OpenSSL/OpenSSL.php`: `encrypt()` return type tightened
to `string|false` to match `openssl_encrypt`; this lets callers'
`=== false` error handling remain meaningful.
- `app/controllers/api/messaging.php`: removed a dead
`array_key_exists('from', [])` branch in the Msg91 provider (empty
array literal; branch was unreachable).
Large cleanup categories across the 549 fixes:
- Removed redundant `?? default` on array offsets and expressions that
PHPStan now knows are non-nullable.
- Removed unreachable statements (mostly `return;` after `throw` or
`markTestSkipped()`).
- Removed redundant `is_array`/`is_string`/`is_bool`/`instanceof` checks
on already-narrowed types.
- Added `default =>` arms (or throwing arms) to non-exhaustive matches
on `string`/`mixed` input.
- Removed dead `$document === false` branches where method return types
were tightened to non-nullable `Document`.
- Removed unused properties (`$version` on Etsy/Zoom OAuth2, `$paths` on
Installer State, `$source` on MigrationsWorker, `$account2` on two
GraphQL auth tests), unused traits (`ApiVectorsDB`, `DatabaseFixture`),
and an unused `cleanupStaleExecutions` task method.
- Replaced `assertTrue(true)` and redundant `assertIsArray`/`assertIsString`/
`assertNotNull` assertions with `addToAssertionCount(1)` or
`assertNotEmpty` where the runtime type was already known.
Cache key never included the project ID, so two projects with the same
bucketId, fileId, and transform params would share a cache key. On a
cache hit, Appwrite re-validates the bucket from the cached resourceType
(another project's bucket), which doesn't exist in the requesting
project's DB, throwing storage_bucket_not_found.
Fix: add 'project' to cache.params on the preview route (covers query
param case) and fall back to the X-Appwrite-Project header in
cacheIdentifier() for authenticated requests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ProviderRepositoryFrameworkList and ProviderRepositoryRuntimeList
model classes with conditions and type field so the listRepositories
endpoint's oneOf response gets a discriminator on the type property.
Each Detection subclass now declares only its own type value in the enum
rather than sharing the full ['runtime', 'framework'] list. This prevents
SDK validators from accepting invalid values on concrete models.
Unify getDiscriminator to produce a single discriminator object for both
single-key and compound cases. Single-key returns standard {propertyName,
mapping}. Compound falls back to extending the object with x-propertyNames
and x-mapping for multi-property discrimination.
Simplify call sites: OpenAPI3 uses 'discriminator', Swagger2 uses
'x-discriminator' — no more split keys.
Add conditions to all 7 Algo models (AlgoArgon2, AlgoBcrypt, AlgoMd5,
AlgoPhpass, AlgoScrypt, AlgoScryptModified, AlgoSha) to enable
discriminator generation for hashOptions unions.
Fix misspelled method name (getDisciminator -> getDiscriminator) across
Format, OpenAPI3, and Swagger2. Extract duplicated model-resolution
lambda into Format::resolveModels(). Fix copy-pasted descriptions in
ProviderRepository list models.