When a filter set quietly becomes a feature
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
Discoverer has a “Not Interested” button. You’re scrolling new releases, you see an album you don’t care about, you dismiss it, and it never shows up again. Behind that button is about the smallest persistence model you can imagine: a SwiftData row with a normalized title+artist key, the catalog id, and a timestamp. That’s it. The discovery feed reads the set of keys on every refresh and subtracts them. The model never needed to know what the album was called - only that it had been dismissed.
Then I picked up the issue to let people see and manage what they’d dismissed. A list. Artwork, title, artist, swipe to restore. And the moment I read my own model back, the gap was obvious: I had no idea how to show any of it.
The problem: I threw the data away on purpose
Here’s what each dismissal stored:
@Model
final class DismissedRelease {
var releaseID: String = ""
var albumKey: String = "" // "fragments\u{1F}bonobo"
var dismissedAt: Date = .now
}
The albumKey is lowercased and trimmed so that matching is stable across the user’s library, the Apple Music catalog, and the dismissal set - three sources that don’t share ids. Great for matching. Useless for display: there’s no way to turn "fragments" back into "Fragments", and a list of lowercased smushed-together strings is not something I want to ship.
So I had two options.
Re-resolve the metadata from MusicKit using the stored catalog id. This sounds clean until you list what it costs: a network round-trip per row, nothing to show offline, and - the killer - MusicKit doesn’t run in the simulator at all, so the whole screen would be untestable without a physical device in hand. It also can’t recover anything for rows whose id no longer resolves.
Store the display data at dismiss time. The dismiss call already receives the full album object. I was holding the title, the artist, the artwork URL - and dropping them on the floor one line later.
When you write it out like that, it isn’t really a decision. The data is free at the moment of dismissal and expensive at every other moment. Capture it.
The solution: widen the model, keep it CloudKit-safe
I added the display fields to the model. Discoverer syncs these rows through CloudKit, which has one rule that bites you constantly: every stored property must be optional or have a default, because CloudKit can’t enforce non-optional constraints. So everything gets a default.
var title: String = ""
var artistName: String = ""
var artworkURLString: String?
var recordingTypeRaw: String = RecordingType.album.rawValue
var releaseDate: Date?
The dismiss path now captures them, and a computed property rebuilds the app’s normal album type for the list (and for undo):
convenience init(album: LibraryAlbum, dismissedAt: Date = .now) {
self.init(releaseID: album.id,
albumKey: AlbumKey.normalized(title: album.title, artist: album.artistName),
dismissedAt: dismissedAt)
title = album.title
artistName = album.artistName
artworkURLString = album.artworkURL?.absoluteString
// ...
}
The two things that bite after the easy part
Old rows have no metadata. Every album dismissed before this change has empty title and artistName. I’m not running a backfill - there’s nowhere to backfill from without the network resolve I just rejected. Instead the list reconstructs a degraded label by splitting the key back apart:
// "fragments\u{1F}bonobo" -> ("fragments", "bonobo")
Lowercased and a little ugly, but visible and restorable, which is the point. New dismissals look right; old ones look lower-case. For a single-user app that’s a fine place to land.
The part I had to convince myself of: does undo still work for those legacy rows? Restoring deletes the dismissal row whose key matches normalize(title, artist). For a legacy row I feed it the lowercased split-apart strings. But normalization lowercases and trims - and doing that to an already-lowercased, already-trimmed string changes nothing. So normalize(split(key)) == key exactly. The display label is lossy; the matching key round-trips perfectly. Undo is safe.
Swift 6 actor isolation, from an angle I didn’t expect. The project builds with default actor isolation set to MainActor, so unannotated types - including the plain struct I rebuild into - get an implicitly main-actor-isolated initializer. Fine, except the computed property doing the rebuilding lives on the @Model class, and the @Model macro’s generated members don’t inherit that default. They’re a little nonisolated island in an otherwise main-actor project. So the build failed:
call to main actor-isolated initializer in a synchronous nonisolated context
The fix is one annotation - mark the property @MainActor, which is true anyway since every caller already runs there. But it’s a good reminder that in a default-MainActor project the isolation comes from the build setting and from which macros opt out of it, not from how the code looks. I’d been bitten by the opposite version of this before (a “pure” helper that was secretly isolated and crashed a background test); this was the same root cause pointing the other way.
What I’d take to the next feature
The real lesson isn’t about SwiftData or actors. It’s that “store the minimum” is the right instinct right up until a model crosses from internal plumbing into something a person looks at. A dismissal key was perfect as a filter. The instant it had to become a row in a list, it needed to carry its own description - and the cheapest moment to attach that description had already passed once, back when I first wrote the dismiss path. I got a second chance because the dismiss call still had the data. Next time I’ll ask earlier: is this thing ever going to be shown to someone? If maybe, capture enough to show it, even when today’s feature only needs the key.
Related reading
The SwiftData test that crashed with no error message
Five tests reporting a bare "Crash": a tidy helper let the ModelContainer deallocate while the store kept its context. A context is a borrowed thing.
I only fixed the screenshot I was asked about, not the ones that were also broken
One recaptured marketing shot looked done - until the deflating question: is this really every screenshot? The other three in the same set were stale too, each in a different way.
A tracklist section, and why it took 30 minutes
One protocol method, a reused state enum, a routing convention that held, and a lint budget that forced a split worth making anyway.