Skip to content

feat(postgrest): add the public transport seam over HTTPTypes - #1271

Open
grdsdev wants to merge 5 commits into
guilhermesouza/sdk-1564-featpostgrest-add-the-postgrestrequest-value-modelfrom
guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes
Open

grdsdev wants to merge 5 commits into
guilhermesouza/sdk-1564-featpostgrest-add-the-postgrestrequest-value-modelfrom
guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes

Conversation

@grdsdev

@grdsdev grdsdev commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Closes SDK-1565. Stage 2 task 3, stacked on #1270.

Settles the open question spec §7 says must be settled before stage 2: "How does a caller inject a transport?"

public protocol PostgrestTransport: Sendable {
  func send(_ request: HTTPTypes.HTTPRequest, body: Data?) async throws
    -> (Data, HTTPTypes.HTTPResponse)
}

Nothing package-scoped appears in that signature, which is the whole reason it can be public. The two alternatives §4.7 lists lose for concrete reasons: promoting HTTPRuntime contradicts an explicit in-source comment (HTTPMethod.swift: "NEVER exposed as public SDK surface") and would put HTTPError under ADR 0001 along with typed throws, HTTPResponseStream, ProgressHandler and HTTPBody; a URLSession-only seam drops the arbitrary interception standalone PostgrestClient users have today.

The pipeline still speaks HTTPRuntime

Per §4.7, HTTPRuntime is reused rather than replaced. This is a façade over it, not a parallel implementation:

What happens
no custom transport HTTPRuntime.URLSessionTransport used directly — nothing converts
custom transport PostgrestTransportBridge adapts it to HTTPRuntime.HTTPTransport

So the conversion cost is paid only by callers who take over the transport.

URLSessionPostgrestTransport is the shipped conformance, implemented over HTTPRuntime.URLSessionTransport rather than duplicating the URLSession handling. It exists mainly to be delegated to — a transport that only wants to observe or adjust should hold one and forward, which keeps URL assembly and its encoding rules in one place.

Three conversion traps, each with a test

The path must not be decoded. URL.path and URLComponents.path both percent-decode, so reading either hands the transport select=* where the wire carries select=%2A. The bridge reads percentEncodedPath/percentEncodedQuery and rebuilds a URL by concatenation rather than through URLComponents, which would re-encode. This is the same hazard #1270 fixed one layer down; it would have been reintroduced here.

A file-backed body is refused, not buffered. Reading the file in would defeat the reason HTTPBody.file exists. PostgREST never produces one, so this throws unsupportedRequestBody rather than quietly working.

Streaming fails loudly. A PostgrestTransport returns a buffered (Data, HTTPResponse). Buffering the whole body and handing back a one-chunk stream would look like streaming while defeating the point of it.

Header names keep the casing they were written with — rawName, not canonicalName, which lowercases for HTTP/2 — and repeated names merge case-insensitively, joined with ", " per RFC 9110 §5.3.

A curl snapshot cannot check the thing that matters

sendsTheQueryStringExactlyAsGiven asserts the recorded URLRequest.url directly, and the comment says why. Written first with Mock.snapshotRequest, it recorded

?select=*&to=eq.+16505555555

while the wire carried ?select=%2A&to=eq.%2B16505555555. That renderer sorts the query items and re-encodes them through URLComponents, normalizing %2A back to * and %2B back to + — precisely the regression the test exists to catch. This is the hazard I flagged on #1270 for HTTPRuntimeTestHelpers.curlCommand, now confirmed for TestHelpers' ._curl too. Worth knowing before SDK-1566 and SDK-1568 lean on those helpers.

The test also proves the strict escaping survives a real URLSession, not just a string assertion — it goes out through Mocker and comes back byte-identical.

Not in this PR

The plan's Task 3 also deletes the hand-rolled retry loop and re-checks db.retry. Both need an execution path on the new core, and the typed API still executes through Legacy/. They belong with SDK-1568, which rebuilds the wrappers, and SDK-1572, which builds the client that holds a transport. Nothing wires a transport into a client yet, so this PR is the seam plus its bridge.

Sources/PostgREST/Legacy/ is untouched.

Verification

  • swift test — 1291 tests in 135 suites pass (18 new)
  • ./scripts/format.sh, ./scripts/spell-check.sh — clean
  • ./scripts/test-docs.sh — clean, no broken DocC links
  • sdk-compliance.yaml — 5 new symbols under supporting_symbols, in its own commit

@grdsdev
grdsdev requested a review from a team as a code owner August 22, 2026 11:02
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 67575903-39e4-4e74-a53b-8e3a76970940

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Capability matrix drift detected

The following capabilities are marked implemented in the matrix but could not be found in swift:

  • client.session_management.persist_session → expected symbol: AuthLocalStorage.defaultLocalStorage
  • functions.invocation.streaming_response → expected symbol: FunctionsClient._invokeWithStreamedResponse

The following capabilities are marked implemented in swift but have no registered symbols to verify:

  • auth.passkey.register_passkey (no symbols list — cannot confirm implementation exists)
  • auth.passkey.sign_in_with_passkey (no symbols list — cannot confirm implementation exists)
  • client.observability.trace_propagation (no symbols list — cannot confirm implementation exists)
  • database.using_modifiers.request_cancellation (no symbols list — cannot confirm implementation exists)
  • functions.invocation.request_cancellation (no symbols list — cannot confirm implementation exists)
  • storage.file_buckets.url_cache_nonce (no symbols list — cannot confirm implementation exists)

These may have been renamed, removed, or never registered. Please update the capability matrix.
See: https://github.com/supabase/sdk/blob/main/docs/capability-matrix.md

@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from fc714de to 6468104 Compare August 24, 2026 08:32
@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from 6468104 to a281c58 Compare August 24, 2026 08:38
@coveralls

coveralls commented Aug 24, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32831869802

Warning

No base build found for commit ee53a2f on guilhermesouza/sdk-1564-featpostgrest-add-the-postgrestrequest-value-model.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 86.13%

Details

  • Patch coverage: 6 uncovered changes across 3 files (145 of 151 lines covered, 96.03%).

Uncovered Changes

File Changed Covered %
Sources/PostgREST/Transport/URLSessionPostgrestTransport.swift 18 15 83.33%
Sources/PostgREST/Transport/PostgrestTransportBridge.swift 77 75 97.4%
Sources/PostgREST/Transport/PostgrestTransportConversions.swift 56 55 98.21%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 11233
Covered Lines: 9675
Line Coverage: 86.13%
Coverage Strength: 84.88 hits per line

💛 - Coveralls

@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from a281c58 to 3fe60d9 Compare August 25, 2026 08:19
@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from 3fe60d9 to 0342e8f Compare August 25, 2026 08:46
@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from 0342e8f to fb9f24c Compare August 25, 2026 09:08
@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from fb9f24c to 654a25e Compare August 25, 2026 09:17
…tems

`HTTPRequestBuilder.build()` assigned `URLComponents.queryItems`, whose
encoding leaves `+` literal. A server that form-decodes the query string
reads a literal `+` as a space, so

    received_at=gt.2023-03-23T15:50:30.511743+00:00
    to=eq.+16505555555

arrive with a space where the UTC offset should be, and without a country
code. Neither fails loudly; both just match the wrong rows.

`QueryEncoding` escapes everything RFC 3986 reserves, keeping only `?` and
`/`, which section 3.4 permits inside a query — leaving those readable in a
log without changing how they parse. It renders the whole query in one pass
so escaping happens exactly once, at build time, on both names and values.
Items are still stored unescaped, so an existing `%` cannot be double-encoded.

This is the same character set `Helpers`' `sbURLQueryAllowed` already applies
on PostgREST's legacy path, which is what makes the port below an equality
rather than an approximation.

Nothing in production imports `HTTPRuntime` yet — only its own tests and
`HTTPRuntimeTestHelpers` — so there is no shipped behavior to preserve.
PostgREST v3 is its first consumer, and the generated clients inherit the fix
before they land.

## Tests

13 tests, including 30 of the 32 requests PostgREST records under
`Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/`, re-expressed
against `HTTPRequestBuilder`. Those are the most varied real query values in
the repository: JSON objects, range literals, `like` patterns, a timestamptz
offset, a non-ASCII string, a leading `+`. All 30 now build byte-identical
URLs to the legacy builder.

The expected strings are not copied from the snapshot files. Those are
written by swift-snapshot-testing's `.curl` strategy, which sorts the query
items by name and re-encodes them through `URLComponents.queryItems` — so
`select=%2A` is recorded as `select=*` and insertion order is lost. Each
string is the real `URLRequest.url` the legacy builder produces, captured by
running the same chains through a fetch handler that prints it.

Two cases are tested separately. The 25-operator case reads better generated
than transcribed. And `rpc call with get and params` cannot be an exact-order
assertion at all: `PostgrestClient.rpc(_:params:get:)` iterates a
`JSONValue` object, so it emits `index=2&array=…` on one run and
`array=…&index=2` on the next — two captures of the same chain disagreed.
The snapshot never caught it because `.curl` sorts before recording.
Parameter order does not change what PostgREST returns, so it is not a wire
bug, but it does make the request unreproducible in a log or a cache key.

`HTTPRuntime` is a target, not a library product, and every symbol in it is
`package`. No public API changes, so no compliance or migration entry.
The note named `PostgrestRequest` as what replaces `PostgrestRequestBuilder`.
That type is not being built — spec §4.7 says to reuse `HTTPRuntime` rather
than invent a parallel request model, and `HTTPRequestBuilder` already
provides the ordered repeated-key query encoding it was meant to add.
`addQuery` always appends, which is right for lists (`?k=a&k=b`) and wrong
for the parameters a server reads once. PostgREST v3 needs `select`, `order`,
`limit`, `offset`, `on_conflict` and `columns` set rather than accumulated,
or a chain that touches one twice leaves two behind and lets the server pick.

`setQuery` replaces the first item using that name, in place, so the items
around it keep their position. Only the first match: a repeated name is
either a list or, in PostgREST's case, a conjunction on one column, and
replacing every match would silently collapse `id=gt.1&id=lt.9` into one
condition. A `nil` value is ignored, matching `addQuery`, so the same
argument behaves the same way in both.

4 tests, including the repeated-name case and the in-place ordering.
`PostgrestTransport` is how a caller takes over sending: a timeout, an
injected header, logging, canned responses in a test — what `fetch:` provides
today.

```swift
public protocol PostgrestTransport: Sendable {
  func send(_ request: HTTPTypes.HTTPRequest, body: Data?) async throws
    -> (Data, HTTPTypes.HTTPResponse)
}
```

Nothing `package`-scoped appears in that signature, which is the whole reason
it can be public. Spec §4.7 records why the alternatives lose: promoting
`HTTPRuntime` contradicts an explicit in-source comment and would put
`HTTPError` under ADR 0001, and a URLSession-only seam drops interception that
standalone `PostgrestClient` users have today.

## The pipeline still speaks HTTPRuntime

Per §4.7, `HTTPRuntime` is reused rather than replaced. `PostgrestTransport`
is a façade over it, not a parallel implementation:

- no custom transport supplied → `HTTPRuntime.URLSessionTransport` is used
  directly, and nothing converts
- a custom transport supplied → `PostgrestTransportBridge` adapts it to
  `HTTPRuntime.HTTPTransport`

So the conversion is a cost only callers who take over the transport pay.

`URLSessionPostgrestTransport` is the shipped conformance, implemented over
`HTTPRuntime.URLSessionTransport` rather than duplicating the URLSession
handling. It exists mainly to be delegated to: a transport that only wants to
observe or adjust a request should hold one and forward, which keeps the URL
assembly and its encoding rules in one place.

## Three conversion traps, each with a test

**The path must not be decoded.** `URL.path` and `URLComponents.path` both
percent-decode, so reading either would hand the transport `select=*` where
the wire carries `select=%2A`. The bridge reads `percentEncodedPath` and
`percentEncodedQuery`, and rebuilds a URL by concatenation rather than through
`URLComponents`, which would re-encode.

**A file-backed body is refused, not buffered.** Reading the file in would
defeat the reason `HTTPBody.file` exists. PostgREST never produces one, so
this throws `unsupportedRequestBody` rather than quietly working.

**Streaming fails loudly.** A `PostgrestTransport` returns a buffered
`(Data, HTTPResponse)`. Buffering the whole body and handing back a one-chunk
stream would look like streaming while defeating the point of it.

Header names keep the casing they were written with — `rawName`, not
`canonicalName`, which lowercases for HTTP/2 — and repeated names merge
case-insensitively, joined with `", "` per RFC 9110 section 5.3.

## A curl snapshot cannot check this

`sendsTheQueryStringExactlyAsGiven` asserts the recorded `URLRequest.url`
directly. Written first with `Mock.snapshotRequest`, it recorded
`?select=*&to=eq.+16505555555` while the wire carried the escaped spelling:
that renderer sorts the query items and re-encodes them through
`URLComponents`, normalizing `%2A` to `*` and `%2B` to `+` — the exact
regression the test exists to catch. Worth knowing before SDK-1566 and
SDK-1568 lean on the same helpers.

## Not in this commit

The plan's Task 3 also deletes the hand-rolled retry loop and re-checks
`db.retry`. Both need an execution path on the new core, and the typed API
still executes through `Legacy/`. They belong with SDK-1568, which rebuilds
the wrappers, and SDK-1572, which builds the client that holds a transport.
`Sources/PostgREST/Legacy/` is untouched.

18 tests. Full suite: 1291 passing.
`PostgrestTransport` underpins every `database.*` capability rather than
belonging to one — it is how a caller takes over sending for any of them — so
it goes under `supporting_symbols`, alongside the other cross-cutting
PostgREST contracts.
@grdsdev
grdsdev force-pushed the guilhermesouza/sdk-1565-featpostgrest-add-the-public-transport-seam-over-httptypes branch from 654a25e to c843481 Compare August 25, 2026 09:25
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Potential Breaking API Changes Detected

This PR appears to contain breaking API changes. Please review the changes below:

API Check Output

If this is intentional, please update your PR title or commit message to include:

  • ! after the type (e.g., feat!: remove deprecated method)
  • Or include BREAKING CHANGE: in the commit body

If this is a false positive, you can safely ignore this warning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants