One recommender, two platforms
Resonance runs on the desktop in Tauri and on Android in React Native. They share a recommender, a database schema and a cloud — without a monorepo, and without the two drifting apart.
When I started the Android version of Resonance, the tempting move was to rewrite the recommender in a mobile-friendly way. I did not, because two recommenders means two sets of bugs — and worse, a playlist that ranks one way on the laptop and another way on the phone.
Copy, but byte for byte
The two apps live in separate repositories. A small script copies the platform-independent files from the desktop — the recommender, karma, taste, smart lists, the sync engine, about thirty files — into the mobile app, unchanged. It has a check mode that compares hashes, so drift shows up as a failing check instead of a surprise. The rule is simple: never edit a copy; fix it on the desktop and copy again.
That only works if the copies can run on the phone untouched. On the desktop they call into Rust through Tauri's invoke. On mobile, a bundler alias points that same import at a shim, which answers the same command names — a search, a radio, lyrics — using a native Kotlin module built on NewPipeExtractor. The copied files never know which platform they are on.
One schema, generated
Both apps sync through the same Supabase project, so their SQLite schemas must match exactly. The migrations live in one place — the desktop's Rust code — and a script parses them into a TypeScript list for the phone. Copying them by hand would have been faster once and wrong forever.
The rows that vanished
The first real sync lost 54 playlist memberships without a sound. Each failed a foreign key: it pointed at a track that existed on the desktop but had never been pushed to the cloud. The row was dropped, the playlist was shorter on the phone, and nothing said why.
The fix does not touch the shared schema. The phone adds a trigger that, when a membership arrives for a track it does not have, creates a placeholder track first — with its update time set to zero, so the real row wins the moment it arrives from the cloud. A background job fills in the placeholders' titles and artists.
CREATE TRIGGER IF NOT EXISTS pt_parent_track
BEFORE INSERT ON playlist_tracks
FOR EACH ROW WHEN NOT EXISTS (SELECT 1 FROM tracks WHERE id = NEW.track_id)
BEGIN
INSERT INTO tracks (id, source, source_id, title, artist,
duration_ms, added_at, updated_at)
VALUES (NEW.track_id, …, '', '', 0, …, 0); -- updated_at = 0: the real row wins
END;A sync that loses data quietly is worse than one that fails loudly.