feat: resolve preview paywall by id instead of fetching all paywalls - #489
feat: resolve preview paywall by id instead of fetching all paywalls#489Makisuo wants to merge 11 commits into
Conversation
The debug/preview flow fetched ALL paywalls for the app just to translate
the deep-link numeric `paywall_id` into the paywall `identifier`, which is
slow and breaks for apps with many paywalls.
Add a single-lookup resolver: `GET /v2/paywalls/resolve?id=<id>` on the V2
API returns `{ id, identifier, name }` for a paywall id, scoped to the
app's public key. The preview flow now resolves the identifier with one
request, then fetches that one paywall exactly as before.
- Add `.paywallsV2` endpoint host (base host, `/v2/` prefix)
- Add `Endpoint.resolvePaywall(byDatabaseId:)` + `Network.resolvePaywallIdentifier`
(authenticated with the public key via `isForDebugging: false`)
- Rewrite `DebugViewController` preview to use the resolver; drop the fetch-all
- Remove the now-unused `Paywalls` list model and `getPaywalls()`
- The "Your Paywalls" multi-paywall picker is removed (it depended on the
fetch-all); previewing one paywall by id is unaffected
Requires the backend resolver endpoint (superwall/paywall-next#3456).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
PR author is not in the allowed authors list. |
There was a problem hiding this comment.
ℹ️ No critical issues — one rough edge and a nit worth a look.
Reviewed changes — the debug/preview flow now resolves a deep-link numeric paywall DB id to its identifier (slug) via a single V2 resolver lookup instead of fetching every paywall for the app and filtering in memory.
- New V2 endpoint host —
API.swiftaddsEndpointHost.paywallsV2and aPaywallsV2config on the samebaseHostwith a/v2/path prefix. - Resolver endpoint —
Endpoint.swiftaddsresolvePaywall(byDatabaseId:retryCount:)→GET /v2/paywalls/resolve?id=<id>and removes the now-unusedpaywalls(). - Network layer —
Network.swiftaddsresolvePaywallIdentifier(forDatabaseId:retryCount:)authenticated with the publicapiKey(isForDebugging: false), replacinggetPaywalls()which used the debug key. - Response model —
Paywall.swiftdrops thePaywallslist model and addsPaywallIdentifierResolution(id,identifier,name). - Debug view controller —
finishLoadingPreview()resolves the identifier in one request;loadPreview()is simplified;pressedPreview()guards against the now-always-emptypaywallslist. - Test —
NetworkTests.resolvePaywall_endpointBuildsRequestasserts the builtGET .../v2/paywalls/resolve?id=123request carries anAuthorizationheader.
I verified the URL builds correctly (/v2/ + paywalls/resolve), that a 404 fails fast (checked in getRequestId before decode, outside the retry closure — no 6× retry against a missing endpoint), and that the auth switch from debug key to public key is intentional and consistent with the resolver being app-scoped. No correctness issues.
ℹ️ CHANGELOG not updated
CHANGELOG.md has no entry for this change. This is debug/preview-only and touches no public SDK API, so it is arguably exempt from the customer-facing-changes convention — flagging only so the omission is a deliberate call rather than an oversight.
Claude Opus | 𝕏
…ew-resolve-endpoint # Conflicts: # Sources/SuperwallKit/Network/API.swift
There was a problem hiding this comment.
ℹ️ No new issues in the incremental changes. The only change since the prior review is a clean merge of
develop; a prior informational rough-edge thread remains open for the author's call.
Reviewed changes — the only change since the prior review is the merge of develop (03c8d9e) into the branch, which required resolving a conflict in API.swift where develop added an mmp endpoint host alongside this PR's new paywallsV2 host.
- Merge conflict resolution in
API.swift— bothEndpointHost.paywallsV2(this PR) andEndpointHost.mmp(fromdevelop) are preserved in the enum, theApistruct fields, theinit, and thegetConfig(host:)switch (which remains exhaustive), with bothPaywallsV2andMMPconfig structs fully defined.
I verified the conflict resolution is complete: mmpHost resolves on NetworkEnvironment (SuperwallOptions.swift:271) so MMP compiles, and none of this PR's own additions (Endpoint.swift, Network.swift, Paywall.swift, DebugViewController.swift) were altered by the merge — the develop-side deltas in those files are the incoming MMP feature, not edits to this PR's resolver logic. The previously-flagged dead preview-picker path in DebugViewController.pressedPreview() is unchanged by this merge and remains open for the author to decide (permanent removal vs. follow-up).
Claude Opus | 𝕏
With the fetch-all gone, the "Your Paywalls" multi-paywall picker can no longer be populated, so `pressedPreview()` was unreachable and the picker chip still advertised a dropdown that did nothing. - Remove `pressedPreview()` and the always-empty `paywalls` property - Drop the picker tap target from the name chip and the preview container - Remove the down-arrow affordance; the chip is now a display-only name label (renamed `previewPickerButton` -> `previewNameButton`) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the only change since the prior review is c6cde4b, which removes the dead multi-paywall picker path that was flagged inline on the previous review. With the fetch-all gone, the picker could never be populated, so the author retired it permanently rather than leaving an inert dropdown affordance.
- Removed the dead picker path — deleted
pressedPreview()and the always-emptypaywallsproperty, dropped the tap targets from both the name chip andpreviewContainerView, and removed theSuperwallKit_down_arrowaffordance. - Made the chip display-only — renamed
previewPickerButton→previewNameButtonand setisUserInteractionEnabled = false, with a comment explaining why it no longer responds to taps.
I confirmed no dangling references to pressedPreview, previewPickerButton, or the paywalls property remain in DebugViewController.swift, and previewNameButton is consistently defined, constrained, and titled in finishLoadingPreview(). This cleanly resolves the prior inline thread with no new issues.
Claude Opus | 𝕏
The V2 API is served from the `superwall.com` domain (api.superwall.com in production, api.superwall.dev in developer), which is a DIFFERENT apex domain than `baseHost` (the legacy v1 API on api.superwall.me). Using baseHost would have sent the resolver to api.superwall.me/v2/... in production and 404'd. Add a dedicated `NetworkEnvironment.apiV2Host` (mirroring `enrichmentHost`, another superwall.com-domain service) and point the `PaywallsV2` host config at it. Local uses localhost:3001 (the apps/api wrangler dev port). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the only change since the prior review is b073dda, which corrects the host the V2 paywall resolver points at: it was reusing the legacy v1 baseHost (api.superwall.me), but the V2 API is served from a different domain.
- New
apiV2HostonNetworkEnvironment—SuperwallOptions.swiftaddsapiV2Hostreturningapi.superwall.dev(.developer),localhost:3001(.local), andapi.superwall.com(default), mirroring the existingenrichmentHostshape. - Repointed
PaywallsV2config —API.swift'sPaywallsV2.hostnow returnsapiV2Hostinstead ofbaseHost, so the resolver hitsapi.superwall.com/v2/paywalls/resolverather than the v1 host.
I confirmed the new apiV2Host switch is consistent with the closest existing analog (enrichmentHost), where .releaseCandidate and .custom likewise fall to the .com default, and that the doc comments accurately describe the v1-vs-v2 host split. This is a correct fix with no new issues, and the sole open thread from the initial review was already resolved.
Claude Opus | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the only change since the prior review is fb00fc48, which switches the V2 paywall resolver's authentication from the app's public API key back to the debugger's signed preview token, matching what the backend actually authenticates the resolve request with.
- Auth switched to the debugger token —
Network.resolvePaywallIdentifiernow sends the request withisForDebugging: true, somakeHeadersusesstorage.debugKey(thesat_token from the deeplink) as the bearer token instead of the app's publicapiKey. - Docs realigned — the doc comments on
Network.resolvePaywallIdentifier,Endpoint.resolvePaywall, andSuperwallOptionswere updated to describe the debugger-token auth. - CHANGELOG entry added — an
Unreleased→Fixesnote now describes the auth correction, addressing the prior informational nit. - Test strengthened —
resolvePaywall_endpointBuildsRequestseedsstorage.debugKey = "sat_test", builds withisForDebugging: true, and now asserts the exactAuthorization: Bearer sat_testheader rather than mere presence.
I traced the ordering: DebugManager.handle(deepLinkUrl:) sets storage.debugKey from the deeplink token param before launchDebugger → loadPreview → resolvePaywallIdentifier runs, so the debug key is always populated by request time. The auth change, docs, CHANGELOG, and test are consistent, and all prior review threads are resolved.
Claude Opus | 𝕏
…endpoint Brings back the multi-paywall picker this branch removed in c6cde4b, without reinstating the fetch-all that made it removable. The picker let you switch previews from inside the debugger. It was backed by the SDK fetching every paywall in full; when that went away the array could never be populated, `pressedPreview()` was unreachable behind a `guard !paywalls.isEmpty`, and the chip advertised a dropdown that did nothing — so it was deleted. Without it, previewing a different paywall means going back to the dashboard for a fresh QR code. paywall-next#3657 adds `GET /v2/paywalls/preview-list`, which returns id/identifier/name for the non-archived paywalls of the application in the debugger's `sat_` preview token — no presentable paywall JSON. That is enough to render the picker at a fraction of the old payload, fetched on demand rather than on every debugger launch. - `PaywallPreviewList` / `PaywallPreviewListItem` decodables - `Endpoint.listPreviewPaywalls` on the `.paywallsV2` host - `Network.listPreviewPaywalls`, same `isForDebugging: true` auth as the resolver, with a lower retry count since it only feeds an optional picker - Restores `previewPickerButton` (name, down arrow, tap target) and `pressedPreview`, now keyed off `previewPaywalls` The list loads after the previewed paywall is on screen, so the picker never delays what the user asked for, and a failure degrades to an empty picker rather than an error. `pressedPreview` needs more than one entry before opening — an action sheet offering only the paywall already on screen is noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — two rough edges inline, plus a coverage gap and a stale PR description.
Reviewed changes — since the prior review, d2aa389 reverses course on the picker: the "Your Paywalls" multi-paywall picker removed in c6cde4b is restored, now backed by a second new V2 endpoint that returns lightweight rows instead of the fetch-all it replaced.
- New
GET /v2/paywalls/preview-listendpoint —Endpoint.listPreviewPaywalls(retryCount:)andNetwork.listPreviewPaywalls(retryCount: = 2)on the existing.paywallsV2host, authenticated with the samesat_debugger token as the resolver. - New response models —
PaywallPreviewList(data,hasMore) andPaywallPreviewListItem(id,identifier,name) inPaywall.swift. - Picker restored in
DebugViewController—previewPickerButtonregains its down-arrow affordance andpressedPreviewtarget,previewContainerView's tap target is back, and thepreviewNameButtonrename plusisUserInteractionEnabled = falseare reverted. - Best-effort list load — a new
loadPreviewPaywalls()populatespreviewPaywallsafter the previewed paywall renders, logging at.warnand leaving the list empty on failure;pressedPreview()now bails unlesspreviewPaywalls.count > 1.
I traced the deep-link → resolve → render → list-load → switch sequence: @MainActor isolation holds, storage.debugKey is non-optional and always set before either request, paywallDatabaseId is always populated by DebugManager.presentDebugger before the load task, and the synthesized Decodable conformances tolerate the extra object / application_id keys the doc comments describe. Two pre-existing rough edges are restored verbatim rather than introduced here (the activity indicator is never stopped on failure paths, and rapid picker switches can race untracked loadPreview() tasks) — both existed on develop before this PR, so I'm not flagging them as regressions.
ℹ️ The new listPreviewPaywalls endpoint has no test
The resolver got resolvePaywall_endpointBuildsRequest when it landed, but the second endpoint added here ships with no equivalent. It's the same class of thing worth pinning down — host, /v2/ path concatenation, and the debugger-token auth header — and CLAUDE.md asks for a unit test alongside new SDK functionality.
Technical details
# Missing endpoint test for `listPreviewPaywalls`
## Affected sites
- `Tests/SuperwallKitTests/Network/NetworkTests.swift:182-206` — only `resolvePaywall_endpointBuildsRequest` exists; nothing covers `Endpoint<EndpointKinds.Superwall, PaywallPreviewList>.listPreviewPaywalls`.
- `Sources/SuperwallKit/Network/Endpoint.swift:251-259` — the untested endpoint.
## Required outcome
- A test asserting the built request for `listPreviewPaywalls` is a `GET` against `…/v2/paywalls/preview-list` carrying `Authorization: Bearer sat_test` when built with `isForDebugging: true`.
- Optionally a decode test for `PaywallPreviewList` against a representative payload, which would also pin down whether `has_more` is genuinely always present (see the inline comment on `Paywall.swift:62`).
## Suggested approach
Mirror `resolvePaywall_endpointBuildsRequest` verbatim — seed `dependencyContainer.storage.debugKey`, call `makeRequest(with:factory:)`, assert method, URL substring, and the exact `Authorization` header.ℹ️ The PR description now contradicts the code
The Behavior change section still states the "Your Paywalls" picker "is removed", and neither the description nor the Rollout section mentions /v2/paywalls/preview-list — so a reader would conclude only one backend endpoint gates this SDK change. The resolved review thread also carries the author's earlier statement that the picker was "permanently retired, not a follow-up", which is worth correcting for anyone reading back through the history.
Technical details
# PR description out of date after the picker restore
## Affected sites
- PR #489 body, "## Change" — lists five files and one endpoint; `d2aa389` adds a second endpoint (`GET /v2/paywalls/preview-list`), two new models (`PaywallPreviewList`, `PaywallPreviewListItem`), and `Network.listPreviewPaywalls`.
- PR #489 body, "## Behavior change" — claims the picker is removed and that `pressedPreview()` "now guards against the empty list"; the picker is restored and the guard is now `previewPaywalls.count > 1`.
- PR #489 body, "## Rollout" — describes a single backend dependency; two endpoints must now exist server-side.
## Required outcome
- Description reflects both endpoints, the restored picker, and the degraded-but-safe behaviour when `preview-list` is unavailable (warn log, empty picker, inert affordance).
## Open questions for the human
- Was the picker restore prompted by feedback, or did the backend `preview-list` endpoint land and make it cheap again? A sentence on that would explain the reversal for future readers.ℹ️ Nitpicks
Sources/SuperwallKit/Debug/DebugViewController.swift:123— the doc comment says the list is "Empty when … the app has a single paywall", but a single-paywall app returns one row; the picker declines because of thecount > 1guard, not emptiness.Sources/SuperwallKit/Models/Paywall/Paywall.swift:38—PaywallPreviewListItemduplicatesPaywallIdentifierResolutionfield-for-field, justified only by the possibility that the two endpoints diverge later. Using the resolution type asdata's element would compile unchanged and drop the duplicate.
Claude Opus | 𝕏
Two review findings on the picker restoration. `hasMore` was a non-optional `Bool` that nothing read. The decoder only applies `convertFromSnakeCase`, so if `preview-list` ever stopped sending `has_more` the whole list decode would throw `keyNotFound` and the picker would silently empty behind a `.warn`. The SDK does not paginate, so the field is dropped rather than made optional — no reason to carry a decode dependency on something unused. `loadPreviewPaywalls()` sat at the end of the render success path, and both the resolve `catch` and the fetch `catch` return before reaching it. The picker therefore only populated when the paywall loaded, leaving the down-arrow inert exactly when switching away is most useful — the case the picker exists for. It now runs from `viewDidLoad` alongside the preview load, concurrently and independently, so a failed render still offers alternatives. Calling it only from `viewDidLoad` also means switching paywalls via the picker no longer refetches a list that cannot have changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — both prior threads are cleanly addressed; one leftover dead-end inline.
Reviewed changes — since the prior review, b708cda addresses both inline threads from d2aa389: the paywall picker's list load no longer hangs off the preview's success path, and the unused-but-required has_more field is gone.
- Picker load hoisted to
viewDidLoad—loadPreviewPaywalls()now runs in its ownTaskalongsideTask { await loadPreview() }instead of being awaited at the end offinishLoadingPreview()'sdoblock, so the picker populates even when the requested paywall fails to resolve or render. Running it only there also means switching previews via the picker no longer refetches a list that cannot have changed. hasMoredropped fromPaywallPreviewList— the model now decodesdataonly, and the doc comment records thatobject,has_moreandapplication_idare deliberately left undecoded so a response-shape change can't turn into akeyNotFoundthat empties the picker.
I traced the new task's ordering and isolation: storage.debugKey is written synchronously in DebugManager.handle(deepLinkUrl:) before the launchDebugger task, and the view controller is only constructed downstream of it, so the picker request can never fire with an empty bearer token. DebugViewController is @MainActor, so both viewDidLoad tasks inherit that isolation and interleave only at await points — no data race on previewPaywalls. The one behavioural gap left is that pressedPreview still refuses to open when paywallDatabaseId is nil, which b708cda newly makes reachable with a populated list.
Claude Opus | 𝕏
Fills the gap left by the picker restoration: `listPreviewPaywalls` had no test, while `resolvePaywall` did. - `listPreviewPaywalls_endpointBuildsRequest` mirrors the resolver's test — GET, correct path, and the `sat_` debug key as the bearer. Also asserts the URL carries no query string: the application is taken from the token's scope server-side, so a client-supplied `application_id` would be a way around that scoping. - Three decoding tests around `PaywallPreviewList`, which declares only `data` while the endpoint also returns `object`, `has_more` and `application_id`. They pin both directions — undeclared fields present and absent — plus the empty-list case, so the response shape can change without a `keyNotFound` silently emptying the picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues — the coverage gap flagged on the prior review is closed. One nit inline on how tightly the endpoint tests pin the URL.
Reviewed changes — since the prior review, 9260802 is test-only: it adds the listPreviewPaywalls coverage the last review asked for, plus decode tests that pin the deliberately-undeclared fields on the picker's response model. No source file changed.
- Endpoint-build test for the picker request —
listPreviewPaywalls_endpointBuildsRequestseedsstorage.debugKey, builds withisForDebugging: true, and asserts theGET, the/v2/paywalls/preview-listpath, the absence of any query string (the application comes from the token's scope), and the exactAuthorization: Bearer sat_testheader. - Three decode tests for
PaywallPreviewList— undeclared fields present, undeclared fields absent, and an emptydataarray, each documenting why the model declares onlydata. - PR description brought current — it now describes the restored picker, both V2 endpoints, the
sat_token auth, and a rollout section that distinguishes404(registered) from401(not deployed), including the production-wiring fix that made the resolver unreachable for three weeks.
I checked the assertions against the code they exercise rather than taking them at face value: Endpoint.makeRequest leaves queryItems nil for this endpoint so the no-? expectation is meaningful, SuperwallRequestData's memberwise init tolerates the omitted requestId, and the decode tests use JSONDecoder.fromSnakeCase — which is exactly EndpointKinds.Superwall.jsonDecoder (EndpointKind.swift:42), so they cover the production path rather than a parallel one. I could not compile or run the suite here (Linux runner, no Xcode), so the tests are reviewed statically.
Claude Opus | 𝕏
…point tests Two more review findings. `pressedPreview` still bailed on `guard let id = paywallDatabaseId`, which was the last thing keeping the picker inert in the case b708cda set out to fix: a debug deep link without a `paywall_id` leaves it nil, so nothing renders, but the now-independent list load populates `previewPaywalls` and the picker refused to open anyway. The id was only used to mark the current row with a checkmark, so it never needed to be non-nil. Replaces both that guard and the `count > 1` check with a single condition — open when the list contains something other than what is already on screen. That still declines on an empty list and on a single entry matching the current paywall, while opening when nothing rendered. The endpoint tests matched only the path, so they passed regardless of which host resolved — exactly how the V2 resolver shipped pointing at the v1 `baseHost` (b073dda). Both now assert host and path together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Problem
The debug/preview flow (open one paywall via a dashboard QR / deep link) fetches ALL paywalls for the app (
GET /api/v1/paywalls) just to translate the deep-link numericpaywall_id(DB id) into the paywallidentifier(slug), then fetches that one paywall by identifier. The fetch-all is O(#paywalls) and breaks for customers with many paywalls (slow / times out).Flow before this PR (
DebugViewController):loadPreview()→network.getPaywalls()(fetch-all)finishLoadingPreview()→paywalls.first { $0.databaseId == paywallDatabaseId }?.identifierpaywallRequestManager.getPaywall(from:)(full paywall by identifier)Change
Replace the fetch-all + in-memory filter (steps 1–2) with a single resolver lookup, then keep the existing full-paywall fetch (step 3) untouched.
GET /v2/paywalls/resolve?id=<id>→{ id, identifier, name }, scoped to the application in the caller's token.API.swift— add a.paywallsV2endpoint host (base host +/v2/prefix; reachable via thesuperwall.com/v2/*→ V2 Worker route).Endpoint.swift— addresolvePaywall(byDatabaseId:retryCount:); remove the now-unusedpaywalls()endpoint.Network.swift— addresolvePaywallIdentifier(forDatabaseId:); removegetPaywalls().DebugViewController.swift—finishLoadingPreview()resolves the identifier with one request instead of filtering a fetched list;loadPreview()simplified.Paywall.swift— remove the now-unusedPaywallslist model; add the smallPaywallIdentifierResolutionresponse model (kept in this existing file to avoid regeneratingproject.pbxproj).Authentication — updated since this PR was opened
The resolver is authenticated with the debugger's signed preview token (
sat_) — thetokenalready carried by the debug deep link (superwall_debug=true&paywall_id=…&token=…) — sent asAuthorization: Bearer <debugKey>viaisForDebugging: true.Not the app's public key, as this description originally said. The debugger's QR preview flow never carries the public key, so the endpoint rejected it; the backend now verifies the
sat_token instead (superwall/paywall-next#3583).The paywall picker — no longer removed
Earlier revisions of this PR removed the in-
DebugViewController"Your Paywalls" picker, because it was populated from the fetch-all. It is now restored, so this PR costs no user-visible capability.superwall/paywall-next#3657 adds
GET /v2/paywalls/preview-list, returningid/identifier/namefor the non-archived paywalls of the application in the samesat_token — no presentable paywall JSON. Enough to render the picker at a fraction of the old payload, fetched on demand instead of on every debugger launch.Paywall.swift—PaywallPreviewList/PaywallPreviewListItemdecodablesEndpoint.swift—listPreviewPaywalls(retryCount:)on the.paywallsV2hostNetwork.swift—listPreviewPaywalls(), sameisForDebugging: trueauth, with a lower retry count since it only feeds an optional pickerDebugViewController.swift— restorespreviewPickerButton(name, down arrow, tap target) andpressedPreview, keyed offpreviewPaywallsTwo deliberate behaviours:
viewDidLoadalongside the preview load, not after it. The picker is most useful precisely when the requested paywall fails to render, so it must not sit behind the preview's success path. A failure logs at.warnand leaves the picker empty rather than surfacing an error.pressedPreviewrequires more than one entry before opening — an action sheet offering only the paywall already on screen is noise.PaywallPreviewListdecodes onlydata. The endpoint also returnsobject,has_moreandapplication_id; none are declared, so a future change to those fields can't throwkeyNotFoundand empty the picker.Testing
NetworkTests.resolvePaywall_endpointBuildsRequest— asserts the endpoint buildsGET …/v2/paywalls/resolve?id=123with an Authorization header.paywall_idand renders the paywall against production.Rollout
Backend ships first. All four backend PRs are merged to
paywall-nextmain:sat_token authpreview-listendpoint behind the picker#3653 matters for rollout. The resolver was registered only in the test-layer wiring, not
server.ts, so/v2/paywalls/resolvereturned401on every host from 2026-07-03 until it was fixed — with a green test suite throughout. Confirm both endpoints return404(not401) for an unauthenticated request before merging:404= registered and reachable.401= not deployed.Preview is debug-only, so an SDK build pointed at an environment without the endpoints degrades to a failed preview (and an empty picker), not a broken app.