Skip to content

[pull] main from appwrite:main - #216

Merged
pull[bot] merged 56 commits into
djacidfx:mainfrom
appwrite:main
Sep 1, 2026
Merged

[pull] main from appwrite:main#216
pull[bot] merged 56 commits into
djacidfx:mainfrom
appwrite:main

Conversation

@pull

@pull pull Bot commented Sep 1, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

ChiragAgg5k and others added 30 commits August 27, 2026 11:47
appwrite/postgres:0.1.0 declares VOLUME /var/lib/postgresql and sets
PGDATA=/var/lib/postgresql/18/docker, but the compose file mounted the
named volume at /var/lib/postgresql/18/data - a sibling of PGDATA, not
PGDATA itself.

Docker therefore satisfied the declared VOLUME with a fresh anonymous
volume on every container creation, and that anonymous volume is where
all Postgres data actually lived. The named appwrite-postgresql volume
stayed empty, so data survived a restart but was discarded by any
container recreate: docker compose down, up --force-recreate, an image
bump, or the documented upgrade entrypoint.

Postgres is the default platform database, so a stock install lost its
console users, projects and data on the first upgrade.

Mount the named volume at /var/lib/postgresql so it covers PGDATA.
The upgrade task accepts --database but always re-derives the engine from
the existing installation and locks it, so the flag was silently
discarded. Someone running `upgrade --database=postgresql` on a 1.9.x
MongoDB install got no indication that the request had been dropped and
that their platform database was still MongoDB.

Default the parameter to an empty string so an explicit value can be told
apart from the default, and warn when it differs from the detected engine.
The warning also states that Appwrite cannot move an existing installation
between database engines, since there is no supported path for it.

Behaviour is unchanged: the detected engine is still what the upgrade uses.
APP_CACHE_BUSTER was raised from 4326 to 4327 when this branch was
prepared for 2.0.0, but 1.9.6 had already shipped 4327. The two releases
therefore carried the same value.

The constant is mixed into Request::cacheIdentifier(), so an installation
upgrading from 1.9.6 kept serving responses cached under 1.9.6 keys
instead of invalidating them.

Bump to 4328 so 2.0.0 has a value no earlier release has used.
Two console schema changes made since 1.9.6 never reached upgraded
installations. The boot-time sync in app/http.php only creates collections
that do not exist yet, so it skips new attributes on collections that are
already there, and V25 had no case for either one:

- projects.onboarding, written by the API shutdown hook on every matching
  request and read by the console "Get started" checklist. Because the
  platform handle drops unknown attributes, the write was discarded
  silently and the checklist stayed empty forever.
- schedules.projectInternalId and its two indexes, written by
  Projects/Http/Schedules/Create.php on every schedule create.

Add both to V25 using the same guarded style as the surrounding cases, so
re-running the migration warns instead of aborting.

Verified by upgrading a seeded 1.9.6 install to 2.0.0 on MariaDB: both
columns go from absent to present, seeded data survives, and a second
migrate run is a no-op.
DocumentsDB is backed only by MongoDB and VectorsDB only by PostgreSQL,
so an installation that uses either needs that engine reachable. The
connection details were effectively hardcoded:

- docker-compose.yml never listed the _APP_DB_*_DOCUMENTSDB variables, and
  listed no _APP_CONNECTIONS_DATABASE_* variables at all. Compose only
  forwards variables it names, so setting them in .env never reached the
  containers and both pools always fell back to mongodb:27017 and
  postgresql:5432.
- Neither family was described in app/config/variables.php, so the
  installer never wrote them to .env either.
- registers.php read _APP_DB_USER, _APP_DB_PASS and _APP_DB_SCHEMA for both
  pools, so the per-engine credential variables that compose did forward for
  VectorsDB were never consumed.

Forward both families plus the two connection-string overrides on every
service that resolves these pools, describe them in variables.php, and read
the per-engine credentials with a fallback to the shared ones so existing
configurations are unaffected.

appwrite-worker-deletes and appwrite-worker-migrations resolve these pools
too and previously received neither family; they now get both.
appwrite-worker-stats-resources inherits them from appwrite-worker.

Verified on a PostgreSQL installation with no mongodb service: pointing
_APP_DB_HOST_DOCUMENTSDB at a MongoDB reachable under another name makes
DocumentsDB create, write and read back, which was impossible before.
_APP_DB_PASS_DOCUMENTSDB and _APP_DB_PASS_VECTORSDB were declared with
filter 'password'. The installer generates a value for any password
variable that has no default, so a fresh install wrote a random secret to
both. Those non-empty values then won the fallback in registers.php, while
the bundled MongoDB and PostgreSQL services provision their user with
_APP_DB_PASS, so both pools authenticated with a password the database
never had.

The generated values are also not sanitised for DSN use: the sanitiser in
generatePasswordValue() only applies to names matching /^_APP_DB_.*_PASS$/,
which these do not.

Clear the filter so both variables install empty and fall back to
_APP_DB_PASS, as the other pool overrides already do.
The dependency was declared through a hand-written package repository that
pointed at the branch cursor/multi-job-queue-platform-f0c8 under the
invented version 1.0.999. Nothing tagged contained the combined worker
initialisation that app/worker.php relies on, so the build depended on a
mutable branch reference that could be force-pushed or deleted.

That work is merged into the monorepo main branch, and packages/platform
there is identical to the pinned commit, so it has been tagged
platform/1.0.0-rc18. Drop the package repository and depend on the tag.

Only utopia-php/platform changes in the lock; every other package keeps
its resolved version.
APP_LIMIT_DATABASE_BATCH was a hard-coded 100, so bulk row and document
operations rejected anything larger with "Value must a valid array no
longer than 100 items". On Cloud a plan raises it through
databasesBatchSize, but self-hosted had no equivalent lever, leaving
operators to split large loads into hundreds of sequential calls.

Read the constant from _APP_LIMIT_DATABASE_BATCH, keeping 100 as the
default so existing installations are unchanged, and forward it to the
services that serve these endpoints. Every call site already reads
$plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH, so the Cloud plan
still takes precedence and only the fallback becomes configurable.

The value is clamped to at least 1 so a malformed setting cannot disable
bulk writes entirely. It is deliberately not capped at the top: the
description warns that memory use and query size grow with it, and the
operator owns that trade-off for their own hardware.
DocumentsDB runs only on MongoDB and VectorsDB only on PostgreSQL, but the
installer provisioned a single engine, the one chosen for the platform. A
default PostgreSQL installation therefore had no MongoDB, so creating a
DocumentsDB database returned 201 and the first collection write failed
with a bare server error, the cause visible only in the logs. A MariaDB
installation lost both products the same way.

Rather than always deploying every engine, ask which products the
installation actually wants:

- _APP_DOCUMENTSDB and _APP_VECTORSDB, both enabled by default, are asked
  during install, seeded from the environment for scripted runs, and
  preserved from the existing .env on upgrade.
- The compose generator adds an engine only when the platform uses it or an
  enabled product needs it, so a deployment runs no database it has no use
  for.
- A disabled product's routes throw GENERAL_SERVICE_DISABLED, for keys and
  privileged roles too, since the engine behind them is not deployed.

Turning a product off now removes its engine instead of leaving an endpoint
that fails on first write.
A product whose engine already backs the platform must not add a second
one: MongoDB with DocumentsDB enabled is a single-engine deployment. The
generator does this, but nothing asserted it, so a future change to how
backing engines are resolved could start provisioning a duplicate without
failing a test.
The CLI install asks whether to deploy DocumentsDB and VectorsDB, but the
web installer had no equivalent, so anyone using it always got both
engines with no way to opt out.

Add a toggle for each alongside the database selector, carry the choice
through the wizard state into the install request, and show both on the
review step so the deployment is confirmed before it starts. The server
maps them onto _APP_DOCUMENTSDB and _APP_VECTORSDB, which the compose
generator already reads, so the engine set follows the same rules as the
CLI path.

Defaults come from the variable defaults, keeping both enabled unless an
existing .env says otherwise.
Two problems with the database product toggles, both from review.

The MongoDB support files were copied only when MongoDB backed the
platform. With PostgreSQL or MariaDB selected and DocumentsDB enabled the
generated compose still declares the mongodb service and bind-mounts
mongo-init.js and mongo-entrypoint.sh, but neither file was written, so
Docker created directories in their place and the container restarted in a
loop. Copy them whenever MongoDB is required by the platform or by
DocumentsDB.

The web installer seeded both products as true in its form state, and
setStateIfEmpty only fills empty values, so a rendered "disabled" default
was ignored and applyStep1State re-checked the box. Upgrading an
installation that had turned a product off silently submitted it as
enabled. Seed both as null and hydrate them from the rendered defaults, as
the other fields already do.

Also drops an orphaned PHPDoc block left above getRequiredBackingServices()
and renames $productEngines to $productToggles, since it maps a namespace to
its environment variable rather than to an engine.
The release branch had fallen 55 commits behind main, so 2.0.0 would have
shipped without the Open Runtimes orchestrator bump to 1.7.2, three OpenAPI
spec fixes, FCM credential validation, dedicated scopes on the DocumentsDB
and VectorsDB endpoints, the subquery limit constants and several lock
fixes.

Three files conflicted, all of them ones this branch had already touched:

- app/config/variables.php and docker-compose.yml conflicted only because
  2.0.x adds _APP_DOCUMENTSDB and _APP_VECTORSDB, which main does not carry
  yet. Both sides were additive, so ours is kept; _APP_LIMIT_DATABASE_BATCH
  already existed on both and is not duplicated.
- composer.lock is taken from main, which is 55 commits newer, with the
  platform pin reapplied. It now differs from main by utopia-php/platform
  alone.

Release metadata is unchanged: APP_CACHE_BUSTER stays 4328,
APP_VERSION_STABLE stays 2.0.0, the README pins and console image tag are
untouched.
Adds a Google slot to the console project's oAuthProviders, following the
same {provider}Enabled / {provider}Appid / {provider}Secret contract that
app/controllers/api/account.php reads at sign-in.

Without it createOAuth2Session('google') fails the Enabled check with
project_provider_disabled, which is what the console currently returns
even though the UI ships a Google button.
The fan-out grouped receivers by their matched subscription IDs so one
encoded frame could serve many connections. Those IDs are ID::unique()
per subscription, so no two connections can ever share one and the key
never collapsed -- it built one group of one, every time. Replaced with
a direct loop over $receivers, which says the same thing in fewer lines.

Behaviour is identical: frames, message totals and byte totals are
byte-for-byte equal across 1..1000 connections and 1..3 subscriptions
per connection.

Also meter outbound bytes. Fan-out cost is driven by payload size times
fan-out width, not by message count, and nothing exposed that: during
OnCall #1175 three pods stepped +18/+78/+226Mi at a message rate of
21/s, below a 74/s peek 70 minutes earlier that cost nothing. The
quantity that actually moved was invisible. $outboundBytes was already
computed for usage accounting; this puts it on a counter too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…upgrade

refactor(install): versioned infrastructure migrations, carrying build artifacts across the 2.0 volume rename
Brings in the CDN certificate provider contract, which moves the certificates
adapter out into utopia-php/cdn, and the fix that isolates parallel function
archives -- the race behind the FunctionsSchedule and General e2e failures on
this branch.
Realtime defaults to one Swoole worker per container instead of
CPU count x _APP_WORKER_PER_CORE (6).

Measured on a single worker holding ~1200 websocket connections: 0.06-0.35
cores, and ~84% of each additional worker's footprint is fixed per-process
overhead rather than connection state (~37MiB fixed vs ~71KiB per
connection). Extra workers also each subscribe to the firehose, so every
worker json_decodes every event, and they split the accept distribution
into a second balancing layer invisible to the deployment (connections per
worker ranged 51-170 inside one container). Concurrency belongs to the
deployment. _APP_WORKERS_NUM still overrides.

Also serialise the event document once per event rather than once per
subscriber. `subscriptions` is the only part of the frame that varies per
connection and it is small, so the rest is encoded once and reused. A
199Hz profile taken during a live fan-out burst put this loop's
json_encode at 26% of on-CPU work, against 2.8% at rest, with 257 of 285
samples on the pubsub callback's encode.

Frames are unchanged apart from key order: 36 comparisons across unicode
escaping, slashes, empty data, nested empties, a pre-set `subscriptions`
key and a blob containing quotes and backslashes all decode identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inside `if ($total > 0)` at least one frame was sent, and every frame
carries a non-empty literal envelope, so $outboundBytes cannot be zero.
PHPStan proves it (greater.alwaysTrue) now that the frame is built by
concatenation rather than json_encode, which could return false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Published SDK READMEs said they were compatible with server version X,
which misled self-hosted users onto Cloud-ahead majors (e.g. Flutter 26
against Appwrite 1.9.6). Spell out that the target is Cloud and that
self-hosted may need an older SDK release.

Co-authored-by: chiragaggarwal5k <chiragaggarwal5k@gmail.com>
Reverts e92e56d, de7f258, 87c3467 and 90b4058, which asked in both
installers which of DocumentsDB and VectorsDB to deploy and had the compose
generator add an engine per enabled product.

Neither product is ready to be turned on from a stock installation, so the
choice is not worth its surface: two installer prompts, a web installer step,
and per-product engine selection. This leaves 2.0.x matching main again for
these files; the replacement, both products off behind an environment variable,
lands on main in #13411 and reaches this branch through the usual sync.
…rsdb-2.0.x

revert: drop the installer choice for the database products (2.0.x)
It only has to change when a response shape changes in a way a cached entry
would get wrong. 2.0 does not, so leaving it at 4327 keeps caches warm through
the upgrade rather than discarding every entry for nothing.
Every step render calls applyBodyDefaults(), which assigned the topology from
the body dataset unconditionally. The dataset carries the value the page was
first rendered with, so moving off step one put it back to combined and the
install request carried combined however the radio was set. Picking "Separate"
produced a combined install: one worker and one scheduler instead of a
container per queue, with no sign anything had been ignored.

It now seeds the same way as every other field, so the dataset supplies a
starting value and a choice already made is left alone. The initial value moves
to null for that -- setStateIfEmpty only fills what is empty, and 'combined'
never was.

(cherry picked from commit b52ded7)
…-values

Normalize execution header response values
ChiragAgg5k and others added 26 commits August 29, 2026 22:00
- Names the worker split "Container topology" rather than "Workers and
  schedulers", since other queues may join the same choice later, and drops the
  row that repeated it on the review screen.
- Sizes the card to the step being shown. A single running maximum meant the
  tallest step set the floor for every other one, so the short ones carried its
  leftover height as dead space. Height now rides the same curve and frame as
  the panel cross-fade, and the outgoing panel holds its own height instead of
  being squeezed as the card resizes.
- Field labels drop to 12px, leaving the section heading above them larger.
- An SSL certificate email that was never entered reads as an "Empty" tag,
  matching how the other absent settings on that panel are shown.
- The account step can be skipped. The installer already skips creating an
  account when either field is blank, so only the form was insisting; half an
  account is still refused.
- HTTPS is on by default, since a public API is normally served over TLS.
  Local and plain HTTP installations have to turn it off.
Brings over DocumentsDB and VectorsDB shipping off by default, the canonical
lowercase proxy rule domains, the certificate domain health check, and the
orchestrator and CDN dependency bumps.
…arry a name

- The HTTPS toggle follows the hostname: off for names no public certificate
  authority will issue for -- loopback names, .local and .internal, and bare IP
  literals -- and on for a real domain. Setting the toggle by hand stops it
  following, so an explicit choice stands. _APP_OPTIONS_FORCE_HTTPS keeps its
  own default; the toggle supplies the value the operator actually installs with.
- The account step takes a name, which the server was deriving from the email.
  A blank one still derives.
- The install endpoint accepts an account with both fields blank, matching the
  task it calls: createInitialAdminAccount() already skips creating one. Only
  the endpoint was insisting, so the step could not really be skipped. Either
  field filled still requires a valid pair.
- Password managers stop offering to sign in on the setup step. They were
  reading the SSL certificate email revealed by the accordion as a login field;
  the fields that are not credentials for this site now opt out. The account
  step keeps its hints, so a manager can still save that one.
…terpolates it

Compose files before 2.0 write the image as
"${_APP_IMAGE:-appwrite/appwrite}:${_APP_VERSION:-latest}", and the tag is
everything after the first colon -- so the version read back was the rest of
that expression. It parses as no version at all, which counts as newer than
every release, so every infrastructure migration looked already applied and
none ran. A 1.9.x upgrade therefore never carried its build artifacts across,
which is the one thing those migrations exist to do.

A tag that does not start with a digit is now left to .env, which holds the
value the expression resolves to.
testProjectVariableInFunction created its function with 'timeout' => 15
and then executed it synchronously, asserting the execution completed.

That timeout is not a handler budget. The executor spends it end to end:
Docker.php bounds the container prepare wait, the launch wait and the
cold-start TCP ping on it, decrementing the remainder after each stage,
and only what survives is left for the function itself. A node-22 cold
start on a contended runner consumes the lot, the executor raises
RUNTIME_TIMEOUT, and the 500 turns the execution status into 'failed'.

The budget was already almost exhausted on the happy path: a passing run
spent 13.76s of the 15s, leaving 1.24s of margin, while a failing run
reached 15.67s and logged "Timed out waiting for runtime." That is the
~20% failure rate this test shows in cloud CI, where it is the only file
in the Project E2E job that creates a function at all.

60 matches setupDeployedFunction in FunctionsCustomServerTest, which was
raised to 60 for the same reason. Assertions are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd docs

The console sign-in credentials were only ever read in app/config/console.php,
so they were absent from the three places every other _APP_* variable is
declared: the committed .env, the appwrite service's environment pass-through,
and the app/config/variables.php registry that documents them.

Without the compose pass-through the variables never reach the container, and
System::getEnv falls back to '' - which surfaces as a live sign-in button that
fails with "provider disabled" and no other diagnostic.

This covers GitHub, GitLab and Bitbucket as well as Google. Those three landed
with the same gap and share the block being edited, so splitting them into a
follow-up would touch these exact lines twice.

Only the appwrite service declares them. The console document is built per
request in app/init/resources.php and every read of the provider triple is on
an HTTP path, so no worker or scheduler needs them - unlike _APP_VCS_*, which
build workers do read.

Defaults stay empty and filter stays '' deliberately: Install.php generates a
random value for any empty var whose filter is 'token' or 'password', which
would stamp junk into a fresh install's OAuth client secret.
…-coldstart-timeout

fix(e2e): stop testProjectVariableInFunction failing on a node-22 cold start
feat(console): allow Google sign-in to the console project
chore: track 2.0.x → main
docs(sdks): clarify Cloud vs self-hosted SDK compatibility
Refactor presence handling to optimize memory usage by storing presen…
perf(realtime): single Swoole worker, encode the document once per event, meter outbound bytes
* fix(builds): cap the billed build duration at the build timeout

Jobs::duration() measures wall clock from buildStartedAt, which the first
log callback stamps — so a build that never streamed a line falls back to
the deployment's creation time and measures its whole queue wait. That
value is what Usage\Build::publish() bills, at memory x duration x cpus,
and it is billed for 'failed' exactly as for 'ready'.

The executor backend could not produce this: it measured microtime()
around the build's own execution, so queue wait was structurally
unbillable. Since builds moved to the jobs-service the mean billed
duration of a *failed* build has gone 111s (Jun) -> 157s (Jul) -> 491s
(Aug) while successful builds held at ~52s, and failed builds are now 57%
of all billed build compute fleet-wide.

One production project shows the shape plainly: 2,286 of its 2,311 failed
August builds have buildStartedAt NULL, empty buildLogs, "exit code -1",
and durations at 904-912s. On s-2vcpu-2gb that is ~1.0 GB-hour billed per
build that never ran — 1,869 of its 1,878 billed GB-hours for the month.

Clamp to _APP_COMPUTE_BUILD_TIMEOUT, the same ceiling Deployments hands
the jobs-service as timeoutSeconds: no job outlives it, so nothing past it
can have been build time. Guarded so a 0 or negative value leaves the
measurement alone rather than zeroing every build.

This is a ceiling, not the whole fix — a starved build still bills the
full timeout. Billing 0 when buildStartedAt is NULL, and the build fan-out
that starves them, are follow-ups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(builds): drop the build duration cap test

Removed at request; the cap in Jobs::duration() is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nshot

testGetScreenshot rendered https://appwrite.io 53 times and asserted the
resulting PNG was larger than 100000 bytes. That is an assertion about the
marketing site's visual design, not about the endpoint. It went red across
every cloud PR on 2026-08-31 when appwrite.io shipped an Init 2.0 splash
screen: the test's cache-buster query makes the request a Cloudflare cache
miss, waitUntil:load snapshots the splash before the page paints, and the
cropped PNG is 10318 bytes. Feeding that render through
Utopia\Image\Image::crop(800, 600) reproduces 10318 byte for byte off CI, so
the failure is the page, not the runner's egress.

Render example.com instead. It is IANA-reserved, carries no JavaScript, and
renders bit-identically across repeated calls and cache-buster queries, which
is why testGetScreenshotComparison already pixel-compares against it and stays
green in the same lane. Assert the output the endpoint actually controls --
that the PNG comes back at the requested 800x600 -- and leave render fidelity
to the pixel comparison that owns it.

The endpoint's SSRF guard rules out an in-cluster target: PublicHostname
rejects any host resolving into a private range and Domain::isKnown() requires
a public suffix, so a hosts override pointing appwrite.io at the test stack
would be refused before the browser is called.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…party-size

test: stop asserting the weight of a third-party page in testGetScreenshot
@pull pull Bot locked and limited conversation to collaborators Sep 1, 2026
@pull pull Bot added the ⤵️ pull label Sep 1, 2026
@pull
pull Bot merged commit 50861ba into djacidfx:main Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants