← Engineering
· 9 min read

Search that forgets


Search is the core of MMO Reconnect. You type a name you half-remember from twenty years ago — Faelor, or was it Faylor? — a server, a guild, and we try to put you back in touch with the person behind it.

That makes search the most privacy-sensitive thing we build, in two directions at once. It has to forget who searched — the system must never be able to say “someone looked for you.” And it has to forget the people who don’t want to be found — a private or suspended claim can’t leak, not even to a determined, signed-in user poking at the edges.

The interesting part is that neither of those is enforced by remembering to be careful. Both are structural. One is an event we refuse to append; the other is a Postgres index that physically cannot contain the rows we’re protecting.

The one event we never append

MMO Reconnect is event-sourced. Almost everything that happens becomes an immutable event in a log, and that log is the source of truth. In a system like that, the reflex is to record everything — events are cheap, and you never know what you’ll want to analyze later.

A search records nothing.

GET /api/search appends zero events. It’s anonymous — you don’t have to be signed in to search — and nothing about the query, including who ran it, is ever recorded. There is no SearchPerformed event, no query log, no “recently searched” table quietly accruing behind the feature. The endpoint is rate-limited per IP to keep it from being abused, and that’s the entire footprint.

This is a deliberate, load-bearing absence. The whole promise of the product is that reconnection is consented-to and one-directional: you can be found through identities you chose to make findable, and no one — including us — gets to watch who’s looking for whom. In an architecture whose defining trait is that it never forgets, the strongest thing you can do for privacy is decide, up front, what you were never going to remember. The most important event in our search feature is the one that doesn’t exist.

Fuzzy matching is a database feature

People misremember names. They transpose letters, they forget a server merged, they’re not sure if the guild was Fire and Fury or Fire & Fury. Search has to be forgiving, which usually sends teams reaching for a separate search cluster.

We didn’t need one. Postgres ships fuzzy matching in the box via pg_trgm — trigram similarity. A trigram is just a three-character slice of a string; Faelor becomes fae, ael, elo, lor (and a couple of padded edges). Two strings are “similar” in proportion to how many trigrams they share, so a typo or a dropped letter still overlaps heavily with the intended name. A GIN index over those trigrams makes that match fast, and Postgres will rank results by similarity for free. One wrinkle: pg_trgm is case-sensitive, so we index a lowercased copy of the name — call it search_name — and lowercase the query to match.

That’s the entire fuzzy-search engine: a column, a trigram index, and a similarity threshold. No Elasticsearch to operate, no second datastore to keep in sync, no reindex pipeline. For a small team at launch, “it’s a database feature” is worth a lot.

A filter is a promise. A partial index is a property.

Now the harder direction: keeping the un-findable un-found.

Some claims must not appear in search. A claim marked Private. A claim an operator has hidden for moderation. Every claim owned by a suspended player. The obvious implementation is a filter — keep every row in the index and tack a condition onto the query:

-- The tempting version. Don't do this.
select ... from search_index
where search_name % @query
  and visibility <> 'Private'
  and not hidden_by_operator
  and not owner_suspended;

We rejected this, and the reason is the whole point of the post. A WHERE filter is a promise — it works only as long as every query remembers to include it. It’s one refactor, one new endpoint, one “quick fix” away from leaking a private claim. The safety lives in the discipline of whoever writes the next query.

Instead, the exclusion is structural. The only trigram index on the table is a partial index, scoped to a single discoverability predicate:

-- The only search path is this index. If a row doesn't satisfy the
-- predicate, it is not in the index — and nothing can match it.
create index search_name_trgm
    on search_index using gin (search_name gin_trgm_ops)
    where visibility <> 'Private'
      and not hidden_by_operator
      and not owner_suspended;

Private, operator-hidden, and suspended-owner rows are physically outside the index. Trigram matching is the only way in, and those rows aren’t in the structure the match traverses. There is no query — however careless, however cleverly constructed — that returns them, because there is no code path that can. It’s provably zero results even for the signed-in owner searching for their own hidden claim. A filter would return them and rely on the WHERE to hide them; the partial index means the data was never reachable to begin with.

The predicate that defines “discoverable” lives in exactly one place — a single shared constant — used by both the index definition and the query. They can’t drift apart into a subtle disagreement, because they’re the same string of truth in two spots.

The difference is the difference between a promise and a property. A filter is something the code agrees to do. A partial index is something the database makes true.

Why a flat table instead of a document

Marten — our event-sourcing and document library on top of Postgres — would happily let us store the search read model as a JSONB document with its own projection. We deliberately didn’t. Search is a flat SQL table, maintained by a raw-SQL projection, for two reasons.

First, search is a SQL-shaped read: a trigram match with similarity ranking over a couple of columns. That’s what relational databases and pg_trgm are good at, and a flat table is the natural home for it.

Second — and this is what settled it — masking is a cross-stream fan-out. When one player is suspended, every claim they own has to leave the search surface at once. Against a flat table that’s a single statement:

-- One player suspended → all their rows leave the index, in one line.
update search_index set owner_suspended = true where owner_player_id = @playerId;

That “one player’s event touches many rows” shape is awkward in a document projection that routes each event to a document by its own key, and it’s exactly what a raw-SQL update expresses trivially. We also looked at Marten’s native ngram search on a JSONB document — the more idiomatic, less-hand-rolled option — and it lost on the one point that mattered most: native ngram indexes can’t be partial, so they can’t give us structural absence. They’d force us back to a WHERE filter, the very thing we were designing out. pg_trgm on a flat table can be partial; that decided it.

Keep the row, flip the flag

Notice what suspension doesn’t do: it doesn’t delete anything. We keep one row per live claim and toggle flags — owner_suspended, hidden_by_operator, the visibility value. Because index membership is derived from those flags, suspend, unhide, and re-tier are all just a flag write that quietly recomputes whether the row falls inside the partial index. Reinstating a player is exact and free — every claim they owned reappears precisely as it was, no replay, no reconstruction.

The alternative — delete the row when a claim goes private, recreate it when it comes back — sounds cleaner but isn’t. The “it’s public again” events carry no character data, so recreating the row would mean replaying the claim’s whole history or stashing the data somewhere else anyway. Keeping the row and flipping a flag is both simpler and reversible.

Off the write path

One more choice hides in here. This read model is asynchronous. It’s maintained by Marten’s projection daemon, catching up to the event log a beat behind the writes, rather than being updated inline as part of each command.

That’s partly because a public search result propagating in a second or two is fine, and partly because of that fan-out again: masking every row a suspended player owns is real work, and it has no business happening synchronously on the write path of some unrelated command. Pushing it to the daemon keeps writes fast and keeps the maintenance logic in one place.

It does raise the stakes on the daemon: a stalled projection means stale or missing search results, so it’s now a production-critical component rather than a background detail — its progress inspectable through Marten if we need to check on it. It also shapes how we test — search tests run the daemon and wait deterministically for the projection to catch up before asserting, never a sleep-and-hope Task.Delay. That “assert after catch-up” pattern became the template for every discovery read model we’ve built since.

The takeaway

“Search that forgets” isn’t a slogan bolted onto a normal search feature. The forgetting is the design.

We forget the searcher by refusing to write the event — privacy as a deliberate hole in an append-only log. We forget the un-findable by putting them outside the only index that search can traverse — privacy as a property of a data structure, not a condition someone has to remember to type. In both cases the safety doesn’t depend on future code being careful. It depends on the event not existing and the row not being there.

In a system built to remember everything, the useful skill turns out to be forgetting on purpose — structurally, provably, in exactly one place.