feat: added New/Updated badges to cluster package lists (#734)

This commit is contained in:
Maciek 2026-09-04 17:46:05 +02:00 • committed by LynithDev
parent 159e1c7e96
commit 4aaf974f04
21 changed files with 378 additions and 23 deletions

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t",
"query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ?\n\t\t",
"describe": {
"columns": [
{
@ -46,17 +46,29 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
"Right": 4
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca"
"hash": "055b35798b158025888aba14bab7c8f5111c1786202f2399f6383d290abfe9cc"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t",
"query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af"
"hash": "071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t",
"query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51"
"hash": "25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5"
}

View file

@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t",
"describe": {
"columns": [
{
"name": "cluster_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "cluster_id"
}
}
},
{
"name": "hash",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "hash"
}
}
},
{
"name": "cluster_file_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "cluster_file_name"
}
}
},
{
"name": "enabled",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t",
"query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f"
"hash": "b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET seen_status = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713"
}

View file

@ -1,6 +1,7 @@
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_content::packages::ProviderId;
use oneclient_core::SeenStatus;
use crate::components::{Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
@ -48,6 +49,8 @@ pub struct PackageEntry {
pub hidden: bool,
/// Only set for browser-installed content bundle packages use the bundle update flow
pub update_available: bool,
/// Recency badge state cleared once the user views the list
pub seen_status: SeenStatus,
}
impl PackageEntry {
@ -63,6 +66,14 @@ impl PackageEntry {
pub fn is_outdated(&self) -> bool {
self.update_available && self.is_remote() && !self.in_bundle()
}
pub fn recency_badge(&self) -> Option<Element> {
match self.seen_status {
SeenStatus::New => Some(new_badge()),
SeenStatus::Updated => Some(updated_badge()),
SeenStatus::Seen => None,
}
}
}
#[derive(PartialEq)]
@ -274,10 +285,13 @@ fn grid_card(
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::wrap_spacing(4.))
.child(badge)
.maybe_child(item.is_outdated().then(outdated_badge)),
.maybe_child(item.is_outdated().then(outdated_badge))
.maybe_child(item.recency_badge()),
),
)
.into_element();
@ -374,7 +388,8 @@ fn package_info(
} else {
local_badge()
})
.maybe_child(item.is_outdated().then(outdated_badge)),
.maybe_child(item.is_outdated().then(outdated_badge))
.maybe_child(item.recency_badge()),
)
.maybe(!item.author.is_empty(), |el| {
el.child(
@ -465,6 +480,28 @@ fn outdated_badge() -> Element {
)
}
fn new_badge() -> Element {
accent_badge(
Icon::new(IconType::Plus)
.size(12.)
.color(colors::success())
.into_element(),
"New".to_string(),
colors::success(),
)
}
fn updated_badge() -> Element {
accent_badge(
Icon::new(IconType::RefreshCcw02)
.size(12.)
.color(colors::success())
.into_element(),
"Updated".to_string(),
colors::success(),
)
}
fn local_badge() -> Element {
badge(
Icon::new(IconType::File02)

View file

@ -301,7 +301,10 @@ pub async fn install_package(
child.finish();
match result {
Ok(_) => installed_dependencies.push(dependency.project.name.clone()),
Ok(artifact) => {
mark_new(cluster_id, &artifact.hash, state).await;
installed_dependencies.push(dependency.project.name.clone());
}
Err(err) => {
tracing::warn!(
dependency = %dependency.project.name,
@ -333,6 +336,10 @@ pub async fn install_package(
child.finish();
if let Ok(artifact) = &result {
mark_new(cluster_id, &artifact.hash, state).await;
}
PackageInstall {
session_id: Some(session.detach()),
result: result.map(|_| project.name).map_err(anyhow::Error::from),
@ -340,3 +347,11 @@ pub async fn install_package(
missing_dependencies,
}
}
async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) {
if let Err(err) =
PackageStore::mark_artifact_new(cluster_id, hash, &state.services.content()).await
{
tracing::warn!(cluster_id, hash, %err, "failed to badge package as new");
}
}

View file

@ -397,6 +397,7 @@ mod tests {
display_version: None,
provider: Some(ProviderId::Modrinth),
published_at: None,
seen_status: oneclient_core::SeenStatus::Seen,
}
}

View file

@ -243,6 +243,9 @@ fn make_row(
hash: installed_info.map(|i| i.hash.clone()),
update_available,
hidden,
seen_status: installed_info
.map(|i| i.seen_status)
.unwrap_or_default(),
}
}
@ -376,6 +379,26 @@ impl Component for PackageManager {
let cluster_id = self.cluster_id;
let content_type = self.content_type;
// Cleared once on mount so the rows already rendered keep their badges for this visit
use_hook(|| {
spawn_forever(async move {
let Ok(state) = crate::launcher::state() else {
return;
};
match oneclient_content::packages::PackageStore::retire_seen_badges(
&state.services.content(),
)
.await
{
Ok(cleared) if cleared > 0 => {
tracing::debug!(cleared, "retired package badges after the list was viewed");
}
Ok(_) => {}
Err(err) => tracing::warn!(%err, "failed to retire package badges"),
}
});
});
// Minecraft reads its content once at startup so a toggle now cannot reach the running session
let session_live = use_game_snapshot().is_active(cluster_id);
let active = use_state(|| 0usize);

View file

@ -7,7 +7,9 @@ use oneclient_db::dao::cluster as cluster_dao;
use oneclient_db::dao::bundle as bundle_catalog_dao;
use oneclient_db::dao::cluster_bundle as bundle_dao;
use oneclient_db::models::ClusterPatch;
use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType};
use oneclient_db::models::{
BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType, SeenStatus,
};
use tokio::sync::Mutex as AsyncMutex;
use futures_util::StreamExt;
@ -584,6 +586,8 @@ async fn reconcile_update(
return Ok(());
}
artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated).await?;
remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await
}
@ -597,7 +601,11 @@ async fn reconcile_addition(
let file_id = addition.new_file.kind.package_id();
let suppression = find_user_suppression(overrides, &file_id);
let enabled = !disable_was_deliberate(addition.new_file.hidden, suppression);
set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await
set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await?;
artifact_dao::set_seen_status(&ctx.db, addition.cluster_id, hash, SeenStatus::New).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(bundles, ctx))]

View file

@ -102,6 +102,7 @@ fn newest<'a>(copies: impl IntoIterator<Item = &'a Copy>) -> Option<String> {
mod tests {
use super::*;
use oneclient_common::domain::ContentType;
use oneclient_db::models::SeenStatus;
fn info(
project_id: Option<&str>,
@ -121,6 +122,7 @@ mod tests {
display_version: None,
provider: project_id.map(|_| ProviderId::Modrinth),
published_at: published_at.map(Into::into),
seen_status: SeenStatus::Seen,
}
}

View file

@ -13,7 +13,7 @@ pub use link::{link_or_copy, remove_entry, try_unlink_materialized};
pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path};
use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao};
use oneclient_db::models::{ArtifactRow, ClusterRow};
use oneclient_db::models::{ArtifactRow, ClusterRow, SeenStatus};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use super::error::PackageError;
@ -157,6 +157,7 @@ impl PackageStore {
let content_type = ContentType::from_repr(artifact.content_type as u8)
.unwrap_or(ContentType::Mod);
let release = artifact_dao::get_release_by_hash(&ctx.db, &link.hash).await?;
let seen_status = link.status();
items.push(LinkedArtifactInfo {
hash: link.hash,
@ -172,12 +173,28 @@ impl PackageStore {
.as_ref()
.and_then(|r| ProviderId::from_repr(r.provider as u8)),
published_at: release.as_ref().and_then(|r| r.published_at.clone()),
seen_status,
});
}
Ok(items)
}
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn mark_artifact_new(
cluster_id: i64,
hash: &str,
ctx: &ContentCtx,
) -> ContentResult<()> {
artifact_dao::set_seen_status(&ctx.db, cluster_id, hash, SeenStatus::New).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn retire_seen_badges(ctx: &ContentCtx) -> ContentResult<u64> {
Ok(artifact_dao::mark_all_seen(&ctx.db).await?)
}
#[tracing::instrument(level = "debug", skip(ctx))]
/// Only the flag is recorded
/// the folder is rewritten at the next launch since a running game holds

View file

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_common::search::normalize_query;
use oneclient_db::models::SeenStatus;
pub const DEFAULT_PAGE_SIZE: usize = 24;
@ -235,4 +236,6 @@ pub struct LinkedArtifactInfo {
pub provider: Option<ProviderId>,
/// Provider publish time RFC 3339
pub published_at: Option<String>,
/// Drives the new/updated badge in the package manager
pub seen_status: SeenStatus,
}

View file

@ -13,7 +13,7 @@ use oneclient_common::domain::ProviderId;
use oneclient_db::dao::artifact as artifact_dao;
use oneclient_db::dao::browser_package_update as update_dao;
use oneclient_db::dao::cluster_bundle as bundle_dao;
use oneclient_db::models::{BrowserPackageUpdateRow, ClusterRow};
use oneclient_db::models::{BrowserPackageUpdateRow, ClusterRow, SeenStatus};
use oneclient_events::GroupedProgressChild;
use crate::ctx::ContentCtx;
@ -360,6 +360,14 @@ pub async fn apply_browser_package_update(
PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?;
}
artifact_dao::set_seen_status(
&ctx.db,
update.cluster_id,
&installed.hash,
SeenStatus::Updated,
)
.await?;
update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?;
Ok(installed.hash)
@ -457,6 +465,7 @@ mod tests {
display_version: None,
provider,
published_at: None,
seen_status: SeenStatus::Seen,
}
}

View file

@ -56,6 +56,7 @@ pub use oneclient_cluster::screenshots::{
ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot,
};
pub use oneclient_content::packages::LinkedArtifactInfo;
pub use oneclient_db::models::SeenStatus;
pub use oneclient_content::packages::updates::{
BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update,
cached_browser_package_updates, check_browser_package_updates,

View file

@ -0,0 +1,19 @@
-- Marks a linked artifact as newly installed or freshly updated, so the package
-- manager can badge it. The status rides on `cluster_artifacts` rather than a
-- side table because that row *is* the identity of "this version of this mod in
-- this cluster": an update installs a new hash and unlinks the old one, so the
-- status can never outlive the version it describes, and removing the mod or
-- the cluster takes it with them.
--
-- Values are the `SeenStatus` discriminants: 0 = New, 1 = Updated, 2 = Seen.
--
-- The default is `Seen`, not `New`. Every artifact already linked when this
-- migration runs, and every artifact laid down by a cluster's first install,
-- must start unbadged -- otherwise a freshly created cluster would light up
-- every single mod. Only the update flows promote a row out of `Seen`.
--
-- The launcher resets every row back to `Seen` on startup, before it applies
-- pending updates, so a badge survives exactly until the next launch.
ALTER TABLE cluster_artifacts ADD COLUMN seen_status INTEGER NOT NULL DEFAULT 2;
CREATE INDEX cluster_artifacts_seen_status_idx ON cluster_artifacts (cluster_id, seen_status);

View file

@ -1,6 +1,6 @@
use sqlx::SqlitePool;
use crate::models::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow};
use crate::models::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow, SeenStatus};
pub async fn get_artifact_by_hash(
pool: &SqlitePool,
@ -192,7 +192,7 @@ pub async fn link_cluster_artifact(
VALUES (?, ?, ?, 1)
ON CONFLICT(cluster_id, hash) DO UPDATE SET
cluster_file_name = excluded.cluster_file_name
RETURNING cluster_id, hash, cluster_file_name, enabled
RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status
"#,
cluster_id,
hash,
@ -214,7 +214,7 @@ pub async fn list_cluster_artifacts_for_project(
sqlx::query_as!(
ClusterArtifactRow,
r#"
SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled
SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status
FROM cluster_artifacts ca
JOIN provider_releases pr ON pr.hash = ca.hash
WHERE ca.cluster_id = ?
@ -270,7 +270,7 @@ pub async fn list_cluster_artifacts(
sqlx::query_as!(
ClusterArtifactRow,
r#"
SELECT cluster_id, hash, cluster_file_name, enabled
SELECT cluster_id, hash, cluster_file_name, enabled, seen_status
FROM cluster_artifacts
WHERE cluster_id = ?
"#,
@ -288,7 +288,7 @@ pub async fn get_cluster_artifact(
sqlx::query_as!(
ClusterArtifactRow,
r#"
SELECT cluster_id, hash, cluster_file_name, enabled
SELECT cluster_id, hash, cluster_file_name, enabled, seen_status
FROM cluster_artifacts
WHERE cluster_id = ? AND hash = ?
"#,
@ -312,7 +312,7 @@ pub async fn update_cluster_artifact(
UPDATE cluster_artifacts
SET cluster_file_name = ?, enabled = ?
WHERE cluster_id = ? AND hash = ?
RETURNING cluster_id, hash, cluster_file_name, enabled
RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status
"#,
cluster_file_name,
enabled,
@ -322,3 +322,45 @@ pub async fn update_cluster_artifact(
.fetch_one(pool)
.await
}
pub async fn set_seen_status(
pool: &SqlitePool,
cluster_id: i64,
hash: &str,
status: SeenStatus,
) -> Result<(), sqlx::Error> {
let status = status.as_i64();
let result = sqlx::query!(
r#"
UPDATE cluster_artifacts
SET seen_status = ?
WHERE cluster_id = ? AND hash = ?
"#,
status,
cluster_id,
hash
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(cluster_id, hash, "no cluster artifact matched the seen status update");
}
Ok(())
}
pub async fn mark_all_seen(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
let seen = SeenStatus::Seen.as_i64();
let result = sqlx::query!(
"UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?",
seen,
seen
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

View file

@ -1,3 +1,4 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, FromRow)]
@ -22,10 +23,41 @@ pub struct ProviderReleaseRow {
pub mc_loaders: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[repr(i64)]
pub enum SeenStatus {
New = 0,
Updated = 1,
#[default]
Seen = 2,
}
impl SeenStatus {
pub fn as_i64(self) -> i64 {
self as i64
}
pub fn from_repr(value: i64) -> Option<Self> {
match value {
0 => Some(Self::New),
1 => Some(Self::Updated),
2 => Some(Self::Seen),
_ => None,
}
}
}
#[derive(Debug, Clone, FromRow)]
pub struct ClusterArtifactRow {
pub cluster_id: i64,
pub hash: String,
pub cluster_file_name: String,
pub enabled: i64,
pub seen_status: i64,
}
impl ClusterArtifactRow {
pub fn status(&self) -> SeenStatus {
SeenStatus::from_repr(self.seen_status).unwrap_or_default()
}
}

View file

@ -8,7 +8,7 @@ mod java;
mod package_metadata;
mod setting_profile;
pub use artifact::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow};
pub use artifact::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow, SeenStatus};
pub use browser_package_update::BrowserPackageUpdateRow;
pub use package_metadata::PackageMetadataRow;
pub use bundle::{BundleRow, NewBundle};