From 487296e75e0c61f19423fa542c6ad43fcfdb3fb8 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:58:31 +0000 Subject: [PATCH] library: stop download writes from invalidating the browse lists The album/artist/playlist paging SQL named song_downloads, so Room invalidated the pages on every download progress tick, janking the list and resetting scroll when a download started. Route the default (filter off) path through a raw query that only observes its own table; the downloaded/offline path keeps the download-aware queries. --- .../com/subtracks/data/db/LibraryDao.kt | 11 + .../com/subtracks/data/db/LibraryPaging.kt | 142 +++++++++++++ .../subtracks/data/repo/LibraryRepository.kt | 195 ++++++++++-------- .../com/subtracks/data/db/LibraryDaoTest.kt | 116 +++++++++++ 4 files changed, 374 insertions(+), 90 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index d0cbc2c9..711da78d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -4,6 +4,8 @@ import androidx.paging.PagingSource import androidx.room3.Dao import androidx.room3.DaoReturnTypeConverters import androidx.room3.Query +import androidx.room3.RawQuery +import androidx.room3.RoomRawQuery import androidx.room3.Transaction import androidx.room3.Upsert import androidx.room3.paging.PagingSourceDaoReturnTypeConverter @@ -432,6 +434,15 @@ interface LibraryDao { ) suspend fun deleteOrphanPlaylistSongs(sourceId: Long) + @RawQuery(observedEntities = [Album::class]) + fun albumsPaging(query: RoomRawQuery): PagingSource + + @RawQuery(observedEntities = [Artist::class]) + fun artistsPaging(query: RoomRawQuery): PagingSource + + @RawQuery(observedEntities = [Playlist::class]) + fun playlistsPaging(query: RoomRawQuery): PagingSource + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME") fun albumsByName( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt new file mode 100644 index 00000000..03215e9f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt @@ -0,0 +1,142 @@ +package com.subtracks.data.db + +import androidx.room3.RoomRawQuery +import com.subtracks.data.prefs.AlbumSort +import com.subtracks.data.prefs.ArtistSort +import com.subtracks.data.prefs.PlaylistSort + +// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so +// the resulting PagingSource is not invalidated by download writes (including progress updates). +internal const val ALBUMS_FILTER_BASE = + "FROM albums WHERE sourceId = ? " + + "AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " + + "AND (? = '' " + + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " + + "WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + + "OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " + + "OR instr(lower(albumArtist), lower(?)) > 0))) " + +internal const val ARTISTS_FILTER_BASE = + "FROM artists WHERE sourceId = ? " + + "AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " + + "AND (? = '' " + + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + + "WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + + "OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) " + +internal const val PLAYLISTS_FILTER_BASE = + "FROM playlists WHERE sourceId = ? " + + "AND (? = '' " + + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " + + "WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + + "OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) " + +internal fun albumsDefaultQuery( + sourceId: Long, + starredFilter: Int, + search: String, + sort: AlbumSort, + descending: Boolean, +): RoomRawQuery { + val order = albumOrder(sort, descending) + return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt -> + stmt.bindLong(1, sourceId) + stmt.bindInt(2, starredFilter) + stmt.bindInt(3, starredFilter) + stmt.bindInt(4, starredFilter) + stmt.bindText(5, search) + stmt.bindText(6, search) + stmt.bindText(7, search) + stmt.bindText(8, search) + stmt.bindText(9, search) + stmt.bindText(10, search) + } +} + +internal fun artistsDefaultQuery( + sourceId: Long, + starredFilter: Int, + search: String, + sort: ArtistSort, + descending: Boolean, +): RoomRawQuery { + val order = artistOrder(sort, descending) + return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt -> + stmt.bindLong(1, sourceId) + stmt.bindInt(2, starredFilter) + stmt.bindInt(3, starredFilter) + stmt.bindInt(4, starredFilter) + stmt.bindText(5, search) + stmt.bindText(6, search) + stmt.bindText(7, search) + stmt.bindText(8, search) + stmt.bindText(9, search) + } +} + +internal fun playlistsDefaultQuery( + sourceId: Long, + search: String, + sort: PlaylistSort, + descending: Boolean, +): RoomRawQuery { + val order = playlistOrder(sort, descending) + return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt -> + stmt.bindLong(1, sourceId) + stmt.bindText(2, search) + stmt.bindText(3, search) + stmt.bindText(4, search) + stmt.bindText(5, search) + stmt.bindText(6, search) + } +} + +private fun albumOrder( + sort: AlbumSort, + descending: Boolean, +): String = + when (sort) { + AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME + AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST + AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR + AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED + AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED + AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT + AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT + } + +private fun artistOrder( + sort: ArtistSort, + descending: Boolean, +): String = + when (sort) { + ArtistSort.Name -> { + if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME + } + + ArtistSort.AlbumCount -> { + if (descending) ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED else ARTIST_ORDER_BY_ALBUM_COUNT + } + + ArtistSort.Starred -> { + if (descending) ARTIST_ORDER_BY_STARRED_REVERSED else ARTIST_ORDER_BY_STARRED + } + + ArtistSort.Frequent -> { + if (descending) ARTIST_ORDER_BY_FREQUENT_REVERSED else ARTIST_ORDER_BY_FREQUENT + } + + ArtistSort.Recent -> { + if (descending) ARTIST_ORDER_BY_RECENT_REVERSED else ARTIST_ORDER_BY_RECENT + } + } + +private fun playlistOrder( + sort: PlaylistSort, + descending: Boolean, +): String = + when (sort) { + PlaylistSort.Name -> if (descending) PLAYLIST_ORDER_BY_NAME_REVERSED else PLAYLIST_ORDER_BY_NAME + PlaylistSort.Added -> if (descending) PLAYLIST_ORDER_BY_ADDED_REVERSED else PLAYLIST_ORDER_BY_ADDED + PlaylistSort.Updated -> if (descending) PLAYLIST_ORDER_BY_UPDATED_REVERSED else PLAYLIST_ORDER_BY_UPDATED + } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index dc364623..e70fda10 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -5,6 +5,9 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.db.albumsDefaultQuery +import com.subtracks.data.db.artistsDefaultQuery +import com.subtracks.data.db.playlistsDefaultQuery import com.subtracks.data.model.Album import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.Artist @@ -148,60 +151,64 @@ class LibraryRepository( pager(40) { val dao = db.libraryDao() val downloadedFilter = if (downloaded) 1 else 0 - when (sort) { - AlbumSort.Name -> { - if (descending) { - dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter) + if (!downloaded) { + dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) + } else { + when (sort) { + AlbumSort.Name -> { + if (descending) { + dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Artist -> { - if (descending) { - dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Artist -> { + if (descending) { + dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Year -> { - if (descending) { - dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Year -> { + if (descending) { + dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Added -> { - if (descending) { - dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Added -> { + if (descending) { + dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Starred -> { - if (descending) { - dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Starred -> { + if (descending) { + dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Frequent -> { - if (descending) { - dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Frequent -> { + if (descending) { + dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Recent -> { - if (descending) { - dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Recent -> { + if (descending) { + dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } } } } @@ -218,44 +225,48 @@ class LibraryRepository( pager(60) { val dao = db.libraryDao() val downloadedFilter = if (downloaded) 1 else 0 - when (sort) { - ArtistSort.Name -> { - if (descending) { - dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter) + if (!downloaded) { + dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) + } else { + when (sort) { + ArtistSort.Name -> { + if (descending) { + dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.AlbumCount -> { - if (descending) { - dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.AlbumCount -> { + if (descending) { + dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Starred -> { - if (descending) { - dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Starred -> { + if (descending) { + dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Frequent -> { - if (descending) { - dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Frequent -> { + if (descending) { + dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Recent -> { - if (descending) { - dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Recent -> { + if (descending) { + dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } } } } @@ -271,28 +282,32 @@ class LibraryRepository( pager(40) { val dao = db.libraryDao() val downloadedFilter = if (downloaded) 1 else 0 - when (sort) { - PlaylistSort.Name -> { - if (descending) { - dao.playlistsByNameReversed(sourceId, search, downloadedFilter) - } else { - dao.playlistsByName(sourceId, search, downloadedFilter) + if (!downloaded) { + dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending)) + } else { + when (sort) { + PlaylistSort.Name -> { + if (descending) { + dao.playlistsByNameReversed(sourceId, search, downloadedFilter) + } else { + dao.playlistsByName(sourceId, search, downloadedFilter) + } } - } - PlaylistSort.Added -> { - if (descending) { - dao.playlistsByAddedReversed(sourceId, search, downloadedFilter) - } else { - dao.playlistsByAdded(sourceId, search, downloadedFilter) + PlaylistSort.Added -> { + if (descending) { + dao.playlistsByAddedReversed(sourceId, search, downloadedFilter) + } else { + dao.playlistsByAdded(sourceId, search, downloadedFilter) + } } - } - PlaylistSort.Updated -> { - if (descending) { - dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter) - } else { - dao.playlistsByUpdated(sourceId, search, downloadedFilter) + PlaylistSort.Updated -> { + if (descending) { + dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter) + } else { + dao.playlistsByUpdated(sourceId, search, downloadedFilter) + } } } } diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 9b0842d4..daa70d8a 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -7,6 +7,7 @@ import androidx.room3.useReaderConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.awaitUntil import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.DownloadStatus @@ -15,6 +16,9 @@ import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source +import com.subtracks.data.prefs.AlbumSort +import com.subtracks.data.prefs.ArtistSort +import com.subtracks.data.prefs.PlaylistSort import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After @@ -24,6 +28,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicBoolean @RunWith(AndroidJUnit4::class) class LibraryDaoTest { @@ -703,6 +708,117 @@ class LibraryDaoTest { } } + @Test + fun defaultPagingQueriesMatchTheDownloadFreeFilters() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Beta", year = 2000, starred = null).copy(albumArtist = "Zed"), + album(sourceId, "b", "Alpha", year = 2010, starred = 5).copy(albumArtist = "Zed"), + album(sourceId, "c", "Gamma", year = 1990, starred = null).copy(albumArtist = "Ann"), + ), + ) + + assertEquals( + listOf("Alpha", "Beta", "Gamma"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Gamma", "Beta", "Alpha"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, true)).page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta", "Gamma"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Year, false)).page().map { it.name }, + ) + assertEquals( + listOf("Gamma", "Beta", "Alpha"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Artist, false)).page().map { it.name }, + ) + assertEquals( + listOf("Alpha"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "alp", AlbumSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Gamma"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "am", AlbumSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Alpha"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 1, "", AlbumSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Gamma"), + dao.albumsPaging(albumsDefaultQuery(sourceId, 2, "", AlbumSort.Name, false)).page().map { it.name }, + ) + } + + @Test + fun defaultArtistAndPlaylistPagingQueriesMatchTheDownloadFreeFilters() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "Beta", albumCount = 3), + artist(sourceId, "ar-2", "Alpha", albumCount = 1), + ), + ) + dao.upsertPlaylists( + listOf( + playlist(sourceId, "p1", "Beta", created = 0, changed = 5), + playlist(sourceId, "p2", "Alpha", created = 1, changed = 0), + ), + ) + + assertEquals( + listOf("Alpha", "Beta"), + dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "a", ArtistSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.AlbumCount, false)).page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta"), + dao.playlistsPaging(playlistsDefaultQuery(sourceId, "a", PlaylistSort.Name, false)).page().map { it.name }, + ) + assertEquals( + listOf("Alpha", "Beta"), + dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Added, false)).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Updated, false)).page().map { it.name }, + ) + } + + @Test + fun downloadWritesDoNotInvalidateTheDefaultPagingSource() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "a", "Alpha", year = null, starred = null))) + dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a"))) + + val defaultInvalidated = AtomicBoolean(false) + val default = dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)) + default.registerInvalidatedCallback { defaultInvalidated.set(true) } + default.page() + + val downloadedInvalidated = AtomicBoolean(false) + val downloaded = dao.albumsByName(sourceId, 0, "", downloadedFilter = 1) + downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) } + downloaded.page() + + db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed)) + + awaitUntil("the downloaded list observes download writes") { downloadedInvalidated.get() } + assertFalse("the default list must not observe download writes", defaultInvalidated.get()) + } + private suspend fun source(): Long = db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))