update UI details for new stream

This commit is contained in:
2026-08-14 12:20:46 -07:00
parent acc4c117f7
commit a36d511d39
18 changed files with 424 additions and 146 deletions
+115 -46
View File
@@ -22,6 +22,8 @@ use crate::{
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
#[derive(Clone)]
pub struct TenantRepository {
db: Db,
@@ -49,17 +51,25 @@ impl TenantRepository {
Ok(())
}
/// Ensure every active tenant has every required singleton component.
/// Partial unique indexes make concurrent starts idempotent. The song
/// component additionally owns a relational revision state row.
/// Ensure every active tenant has an initial instance of each built-in
/// component. Registration normally creates these rows; this startup
/// backfill keeps upgrades from older releases complete. Additional
/// instances are created explicitly through the component API.
pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> {
for tenant in self.db.list_active_tenants().await? {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, tenant.user_id).await?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&tenant.user_id],
)
.await?;
let existing = transaction
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?;
@@ -84,11 +94,17 @@ impl TenantRepository {
)
.await?;
transaction
.query_one(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?
.ok_or_else(|| {
RepositoryError::Invalid(
"failed to create the initial song request component".into(),
)
})?
.get(0)
};
transaction
@@ -98,38 +114,58 @@ impl TenantRepository {
&[&tenant.user_id, &component_id],
)
.await?;
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
let has_gift_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?;
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
],
.await?
.get::<_, bool>(0);
if !has_gift_effect {
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
}
let has_gift_menu = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_MENU_KIND],
)
.await?;
.await?
.get::<_, bool>(0);
if !has_gift_menu {
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
],
)
.await?;
}
transaction.commit().await?;
}
Ok(())
@@ -162,12 +198,6 @@ impl TenantRepository {
kind: &str,
name: &str,
) -> Result<ComponentInstance, RepositoryError> {
// Every tenant receives this singleton during registration/startup.
// Keeping creation internal prevents a second instance from racing the
// partial unique index and turning a domain conflict into a DB error.
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) {
return Err(RepositoryError::Forbidden);
}
let runtime = self
.registry
.runtime(kind)
@@ -190,6 +220,26 @@ impl TenantRepository {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
// Serialize instance-count checks for this account. Without the owner
// row lock, concurrent requests could both pass the per-kind limit.
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&owner_id],
)
.await?;
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &component.kind],
)
.await?
.get::<_, i64>(0);
if instance_count >= MAX_COMPONENT_INSTANCES_PER_KIND {
return Err(RepositoryError::Invalid(format!(
"a component kind accepts at most {MAX_COMPONENT_INSTANCES_PER_KIND} instances"
)));
}
transaction
.execute(
"INSERT INTO component_instances \
@@ -205,6 +255,15 @@ impl TenantRepository {
],
)
.await?;
if component.kind == SONG_REQUEST_KIND {
transaction
.execute(
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
VALUES($1,$2)",
&[&owner_id, &component.id],
)
.await?;
}
transaction.commit().await?;
self.cache.upsert(component.clone());
Ok(component)
@@ -218,6 +277,12 @@ impl TenantRepository {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&owner_id],
)
.await?;
let kind: String = transaction
.query_opt(
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
@@ -226,10 +291,14 @@ impl TenantRepository {
.await?
.ok_or(RepositoryError::NotFound)?
.get(0);
if matches!(
kind.as_str(),
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
) {
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &kind],
)
.await?
.get::<_, i64>(0);
if instance_count <= 1 {
return Err(RepositoryError::Forbidden);
}
let changed = transaction