Skip to Content

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 offered532,234
Pages that exist500,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 + rows is 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.

  1. Send POST /api/files/signed-url. The response has an upload URL and a token.
  2. Send the file to that URL with an HTTP PUT.
  3. Send POST /api/files with 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.

VariablePurpose
FILES_MAX_UPLOAD_SIZEMaximum size in bytes, when the bucket sets none. Default 104857600 (100 MB).
FILES_MIME_TYPE_ALLOW_LISTPermitted content types, when the bucket sets none. Default: all types.
FILES_STORAGE_BUCKET_ALLOW_LISTBuckets that a signed upload can target. A bucket cannot set this.
FILES_UPLOAD_TOKEN_SECRETSigns 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/info gives the node and os blocks to administrators only.
  • /api/server/health gives { status } to anonymous callers. Administrators get the full IETF-style body.

Other changes to these endpoints:

  • os.version reports the operating system release. Before, it repeated the Node version.
  • In the health body, observedValue is a number and observedUnit holds the unit. Raw error text moved to output.
  • 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 project block reads daas_settings with a 60-second cache, and falls back to environment values.
  • All three server endpoints send Cache-Control: no-store.

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_name

If 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.