Workstation4 / Blog / web
web social iOS API

Public profiles, universal links, and a follow graph that stays bounded.

For most of its life Pinball Points has been a private notebook: you snap a scoreboard, the app reads the digits, and your stats stay yours. The work from this week is the first time any of that data leaves the app on purpose. Players can now opt into a public profile, share it as a real web link, and follow each other. None of that is hard to describe, but a lot of it is easy to get wrong, so the time went into keeping it bounded, safe, and consistent with the sync engine the rest of the app already uses. Here is what I learned building it across three pieces: the SwiftUI app, a Fastify API, and a PocketBase database.

Opt in, then render server-side

The first rule is that nothing is public unless a player asks for it to be. A profile lives in a profiles collection with three fields that matter here: a slug, a displayName, and an isPublic boolean. Until isPublic is true there is no page, no link, and nothing to resolve. That single flag is the gate for everything downstream, and every read path re-checks it. The slug query is literally slug = "..." && isPublic = true; flip the profile back to private and the page, the link, and any follow that pointed at it all stop resolving in the same instant.

When a profile is public, the page is rendered server-side by the API at GET /u/:slug, straight from synced score data. That choice felt important. The numbers behind a profile are the same ones the app already pushed through the operation-log sync, so the web page is not a second source of truth; it is a read view over the first one. The renderer pulls a player's non-deleted scores (capped at 1000), derives a small stats block, and emits a self-contained HTML document with Open Graph tags so the link unfurls nicely when shared.

The stats are computed on the fly rather than stored, which keeps them honest. Total games is just the row count; unique machines is the size of a Set over machine names; top score is a Math.max with a zero floor so an empty profile reads as zero rather than -Infinity:

const uniqueMachines = new Set(scores.map((s) => s.machineName)).size;
const topScore = Math.max(0, ...scores.map((s) => s.playerScore));
const topEntry = scores.find((s) => s.playerScore === topScore);

Because a popular profile could be fetched repeatedly, the lookup sits behind a small in-memory LRU cache: 500 entries, a five-minute TTL, a doubly linked list plus a Map for O(1) promotion and eviction, and no external dependency. A miss returns undefined while a known-absent slug is cached as null, so even "this profile does not exist" is cheap to answer the second time. The one rule the cache has to honor is invalidation: when a player edits their slug or toggles visibility, the sync engine evicts both the old and new slug so a stale page can never linger past an edit.

Claiming a slug without collisions

A profile URL is pinballpoints.com/u/<slug>, so the slug is the player's public identity and it has to behave. Three checks run before one is accepted, both at claim time and again on every public read so a malformed slug can never reach the database:

  • Length between 3 and 30 characters.
  • A shape regex, ^[a-z0-9][a-z0-9-]*[a-z0-9]$: lowercase alphanumerics and internal hyphens only, never a leading or trailing hyphen.
  • A reserved set, so nobody can claim admin, api, app, settings, profile, and friends, the words I might want as real routes later.

There is a live GET /api/check-slug endpoint so the iOS edit screen can show availability as the player types, and the save path re-validates everything server-side. Trusting the client to have done the validation would defeat the point.

A shareable profile is only useful if the link behaves well. I wanted a single URL that opens the native app for people who have it and falls back to the website for everyone else, with no clumsy interstitial asking the visitor to choose. That is exactly what Apple's universal links give you, and wiring them turned out to be two small pieces.

On the app side, the entitlements file declares an associated domain, applinks:pinballpoints.com, and the app's scene handles the incoming URL. When iOS sees a matching link it hands the whole NSUserActivity URL to the app instead of opening Safari, and SwiftUI surfaces it through .onOpenURL. The handler does the smallest possible thing: peel the slug off the path and present the in-app profile.

.onOpenURL { url in
    let path = url.path()
    if path.hasPrefix("/u/") {
        deepLinkSlug = String(path.dropFirst(3))
    }
}

On the server side, the domain hosts the matching association file declaring which paths belong to the app. The result is one link that does the right thing in every context. Sent to a friend with the app installed, it opens the profile in a sheet without ever loading a web page. Posted somewhere public, the same URL renders as a normal page with Open Graph metadata for anyone curious enough to tap through. The website stops being a separate surface and becomes a doorway back into the app.

A follow system that stays bounded

Following sounds like the simplest feature here, and it is quietly the trickiest, because a social graph is the part of an app most likely to grow in ways you did not plan for. The follow data lives in a tiny follows collection: a row is just a follower id and a following id. The interesting part is the indexes, because the database enforces the rules I care about most:

CREATE UNIQUE INDEX idx_follows_pair ON follows (follower, following)
CREATE INDEX idx_follows_follower ON follows (follower)
CREATE INDEX idx_follows_following ON follows (following)

The unique compound index is the real duplicate guard: even if two requests race, the second insert of the same pair cannot land. The two single-column indexes keep the read paths fast in both directions, who I follow and who follows a given user, which is exactly what the feed and the lists need.

Players follow each other by slug, and that slug only resolves to a user when the profile is public. A private profile is not just hidden from the web; it is unfollowable, because there is no public identity to point at. On top of the database guarantees, the write path enforces a few invariants in application code so the API can return a friendly reason rather than a raw constraint error:

  • You cannot follow yourself. A self-follow is rejected with a 400 before anything is written.
  • Re-following someone you already follow is idempotent: the endpoint detects the existing row and returns { ok: true } instead of erroring, so a double tap is harmless.
  • There is a hard cap of 200 on how many accounts one person can follow, checked before the insert.

That cap is the rule I expect to matter most over time. An unbounded follow count is an unbounded feed, an unbounded fan-out, and an open door for an abusive account to follow everyone. Capping the out-degree at 200 keeps the graph and the feeds it drives within a size I can reason about, and it lets the feed query stay a single bounded "scores from these users" lookup rather than something that grows without limit.

A social graph is much easier to keep small than to shrink. I would rather raise a cap from 200 later than discover I needed one too late.

A feed without an N+1

The feed reads scores from everyone you follow, sorted newest first, with cursor pagination on playedAt. The naive version fetches the scores and then looks up each author's display name one query at a time, which is the classic N+1. Instead, the route collects the unique author ids from the page of scores and fetches all their profiles in one OR-filtered query, then joins them in memory through a Map:

const orFilter = uniqueUserIds
  .map((id) => `user = "${escapeFilterValue(id)}"`)
  .join(" || ");
const profiles = await pb.collection("profiles").getList(1, uniqueUserIds.length, {
  filter: orFilter, skipTotal: true,
});
const profileMap = new Map(profiles.items.map((p) => [p.user, p]));

That keeps a page of feed to two queries regardless of how many friends contributed to it. Note the escapeFilterValue wrapper on every interpolated value: PocketBase filters are a string DSL, so user-controlled ids and slugs get escaped before they ever touch a filter, the same way you would parameterize SQL. The head-to-head compare endpoint uses the same shape, firing my scores and a rival's scores on a given machine as two parallel queries with Promise.all rather than one after the other.

Riding the same sync backend

Profiles and follows do not get a bespoke service. They ride the same migration-aware backend that already powers scores: stable operation ids, idempotent writes that survive retries, per-user serialized locking so two devices cannot race, an explicit field allowlist so a client can only write the fields the schema expects, and the compaction plus snapshot fallback that keeps stale clients converging. A follow is just another record type on that op-log, so I did not have to reinvent conflict handling or convergence for it. One sync engine, several record types, one set of guarantees to keep correct.

What I took away

  • Make "public" an explicit, reversible flag, and let that one boolean gate rendering, link resolution, and follows alike. Re-check it on every read, not just at write time.
  • Render shared pages from the synced source of truth, computing stats on the fly, so a profile can never disagree with the app. A small LRU cache makes that cheap as long as you remember to invalidate on edit.
  • One universal link beats two surfaces: an associated-domains entitlement plus a tiny onOpenURL handler is all it took to make the same URL open the app when present and the web otherwise.
  • Let the database enforce what the database is good at. A unique compound index made duplicate follows impossible; application checks just turned the failures into friendly messages.
  • Decide the bounds of a social graph before you have one. A 200-follow cap and an idempotent follow are cheap up front and painful to retrofit, and they keep the feed query bounded for free.

Profiles turned a private tracker into something players can share without ever exposing a number they did not choose to make public. That balance, social on purpose and private by default, is the part I cared most about getting right.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →