v0.1.93 — 26 Aug 2026
Fixed — pagination no longer offers pages that hold nothing
Who is affected: Every consumer of list endpoints on large collections, and anyone who browses one in Platform Studio.
What was wrong
v0.1.89 made meta.total the estimate of the query
planner on large collections. This took the count from about 139 ms per request
to about 0.15 ms.
An estimate is only as good as the statistics of the table. Nothing after that point treated the number as approximate. The list UI divides the total by the page size. It then draws that many page buttons.
One collection held 5,002,621 rows. The planner reported 5,322,335 rows:
| Pages the list offered | 532,234 |
| Pages that exist | 500,263 |
| Pages that show an empty table below a footer that claims rows | ~31,971 |
The drift does not correct itself. Autoanalyze runs when about 10% of the table changes. A bulk load or a purge below that limit leaves the numbers stale for an unlimited time.
Filtered sets drift the other way, and that direction hides data. One filter estimated 1,001 rows against 1,463 real matches. The pager stopped at page 101, so a third of the matches stayed out of reach. A direct request did not help. PostgREST refuses a page that starts after the total that it counted. Every request after page 101 came back empty, but the rows were there.
What changed
The page and the count must now agree. Every list read gets a page of rows, and that page is evidence about the true end of the result set. The server discarded this evidence before. Three checks now use it:
- A page can end inside its own window, and the count can agree that the rows
stop there. In that case
offset + rowsis the total. The server does no more work. - Each page asks for one row more than it needs. That row separates a full page from a page that has more rows after it. A full page can then set a lower limit for the total.
- The page and the count can disagree. Three cases apply: an empty page inside the reported range, rows after the end of that range, or a short page that the count disputes. In that case the server counts the rows exactly and returns the correct total.
PostgREST can refuse a page because the page starts after the counted total. The server now reads that page again without a count, which PostgREST cannot refuse. If rows come back, the count was too low, and those rows are the correction. If no rows come back, the request was truly after the end.
The second read has a cost. A deep offset makes Postgres walk the rows that it skips. The cost stays inside one page. An exact count instead lets any page number cause a full scan.
Only the disagreement case causes a scan, and only for the request that reaches the boundary. Ordinary pages do not change. The estimate stands, the count keeps its ~0.15 ms, and the v0.1.89 performance work stays intact.
A short page alone is not proof that the rows ran out. PostgREST truncates a
response at db-max-rows (1000 on BuildPad deployments) in the same way. A
?limit=-1 read over five million rows returns one thousand rows. A total
taken from those rows reports the collection as 1000 rows. If the count
disagrees with a short page, the server counts again. A truncated response
never becomes a total.
meta.total_estimated
List responses carry a new boolean. It says whether the total is exact:
{
"data": [],
"meta": { "page": 1, "limit": 25, "total": 5322335, "total_estimated": true }
}false means that the number is certain. The page proved it, the server
replaced a count that the page contradicted, or the request asked for
?count=exact.
true means only that this response did not prove it. count=estimated also
returns exact counts on small collections, and a response cannot tell those
apart from a planner estimate. Read true as “not guaranteed”. Do not read it
as “definitely approximate”.
In Platform Studio
A total can shrink while a list is open. The server can correct an estimate, or someone can delete rows. Collection lists now step back to the last page that exists. Before, the browser stayed on an empty page.
Added — direct file upload to storage
Who is affected: Anyone who uploads large files.
Before this release, every upload went through the API server. The server limits the size of a request body, so large files failed.
You can now send a file directly to storage. The file does not pass through the API server.
- Send
POST /api/files/signed-url. The response has an upload URL and a token. - Send the file to that URL with an HTTP
PUT. - Send
POST /api/fileswith the token. This step registers the file.
If the upload fails, send DELETE /api/files/signed-url to discard the object.
The client cannot choose the storage identity of the record. The server reads the id, the disk filename, the bucket, the folder, and the type from the token. Each token belongs to one user, is valid for two hours, and works one time only. The server also reads the file size and the content type back from the stored object. It does not trust the values in the request.
The maximum size and the permitted content types come from the storage bucket. You set them in Supabase Studio, and Supabase enforces them on the upload itself. If the bucket leaves a field unset, the server uses the environment variable for that field. A new bucket sets neither field, so a deployment that never opened Studio keeps the behavior that it had. A bucket value can raise a limit as well as lower it.
The server reads the bucket configuration one time per minute for each client, so a change in Studio takes effect inside that minute. If the server cannot read the bucket, it writes a log message and uses the environment values. Supabase still enforces the real limit on the upload.
All four paths that record a file resolve the limits through the same helper.
The multipart flow and the signed flow cannot disagree with each other, or with
the bucket. A content-type test accepts the image/* form of a wildcard, and
ignores a ; charset= parameter on the type that storage recorded.
| Variable | Purpose |
|---|---|
FILES_MAX_UPLOAD_SIZE | Maximum size in bytes, when the bucket sets none. Default 104857600 (100 MB). |
FILES_MIME_TYPE_ALLOW_LIST | Permitted content types, when the bucket sets none. Default: all types. |
FILES_STORAGE_BUCKET_ALLOW_LIST | Buckets that a signed upload can target. A bucket cannot set this. |
FILES_UPLOAD_TOKEN_SECRET | Signs the upload tokens. Falls back to SUPABASE_SERVICE_ROLE_KEY. |
FILES_STORAGE_BUCKET_ALLOW_LIST stays an environment variable. It is not
bucket configuration, and it has no equivalent in Studio. The server mints
signed URLs with service-role credentials, which can write to every bucket in
the project. This list is what limits the Files API to the correct buckets.
If you change FILES_UPLOAD_TOKEN_SECRET, every token that is in flight stops
working. Change the secret at a time when no upload is in progress.
The Files page in Platform Studio uses this flow. A large upload through the Studio no longer passes through the API server. If storage refuses the file because of its size, the Studio now shows that reason instead of a generic failure.
See Files API for the full flow.
Changed — /api/health is deprecated and now tests the database
Who is affected: Any deployment that monitors /api/health.
/api/health is now an alias of /api/server/health. The old endpoint always
answered 200 and did not test the database, which made it a copy of
/api/server/ping.
This changes what your monitor sees. If you use /api/health as a liveness
probe, move that probe to /api/server/ping. A liveness probe that tests the
database restarts your containers when the database is unavailable, and a
restart does not fix the database.
Use /api/server/ping for liveness. Use /api/server/health for readiness.
/api/server/specs/oas now serves the same OpenAPI document as
/api/docs/openapi.json.
Changed — server endpoints show less to anonymous callers
Who is affected: Anyone who reads /api/server/info or /api/server/health
without an administrator account.
/api/server/infogives thenodeandosblocks to administrators only./api/server/healthgives{ status }to anonymous callers. Administrators get the full IETF-style body.
Other changes to these endpoints:
os.versionreports the operating system release. Before, it repeated the Node version.- In the health body,
observedValueis a number andobservedUnitholds the unit. Raw error text moved tooutput. - The database test has a timeout of 3 seconds and a 5-second single-flight cache. Anonymous callers cannot cause unbounded database load or a hung probe.
- The
projectblock readsdaas_settingswith a 60-second cache, and falls back to environment values. - All three server endpoints send
Cache-Control: no-store.
Added — user_created and user_updated link to the user record
Who is affected: Collections that you create after this release.
The New Collection wizard gave these two fields a plain uuid column. The
column held a user id, but the database did not know that the id pointed at
daas_users.
These fields are now many-to-one relations. The wizard creates a foreign key and a relation record, so a query can expand them:
GET /api/items/articles?fields=id,user_created.first_nameIf you delete a user, the database sets these fields to null. The delete is not blocked. The foreign key column has an index, so a delete does not scan every collection table.
This change applies to collections that you create after this release.
Existing collections keep their plain uuid columns.
Internal
ItemsService.readByQueryWithCount returns countIsEstimated with items and
count. The scope, filter, relational, and search conditions now go through one
shared function. A count can no longer describe a different set of rows than the
page that it accompanies. The OpenAPI spec now documents ?count=. v0.1.89 did
not document it.